fix(chat): 她主动说的话实时进主会话(W50 proactive_message 消费 + 帧解码修复)
缺口:她主动说的话既是实时帧、也是主会话历史里的一条 assistant 行,而安卓端实时看不到 —— ChatViewModel.onEvent 里没有 ProactiveMessage 分支(落到 else -> Unit),另两个下行消费者也不认它; 而且帧里只解了 messageId(IM 侧的号),落库行的 id / createdAt 被 ignoreUnknownKeys 静默丢弃。 后端这条 Web 路由只发这一帧、不补 message_persisted,所以没有别的路能把它带进来。 一、帧解码(data/ws/WsEvent.kt) - ProactiveData 与 ProactiveMessage 补上 id 与 createdAt(落库行的权威 id 与时间戳,同现同省)。 - 顺带修掉一个把整帧弄没的缺陷:载荷里的 timestamp 服务端是 Go time.Time,线上是 RFC3339 字符串, 而本端按 Long? 解 ⇒ 解码抛异常、被 parse 的 runCatching 吞成 null,帧在成形之前就没了 (所以只加一条 onEvent 分支根本碰不到它)。改成按线上的字面量收,数字形状也照样读出。 二、为什么按已落库行并入,而不是画一个只活在 UI 的气泡 真相是服务端历史里的那一行,实时帧只是它早到一步的副本:按 HistoryMessageDto.id 走既有的 mergeMessages 并入 ⇒ 实时能看到,且刷新 / 重开 / 切会话拉历史之后她的话仍在;同一条 (实时帧 + 握手回放 + 分页)无论到几次都只有一行,不需要任何额外去重。 判据抽成纯函数 proactiveIngestFor(ui/chat/ProactiveIngest.kt),可单测。 三、为什么必须自己钉死主会话 proactive_message 帧壳不带 sessionId(服务端构造这一帧时没设它,且该键是 omitempty), 而 isForSession 对空 sessionId 的口径是放行 ⇒ 这一帧会投给每一个活着的 ChatViewModel。 不钉就会在每个次级会话里都冒出一条她的话(比看不到更糟)。判据是等值比较、不是前缀: 主会话 web 与次级会话 web:xxx 是两个会话。 四、无 id 时为什么退化而不是丢弃 id 与 createdAt 一起缺席 = 服务端那一步落库失败了(它自己就说了这条没有历史行)。这时画一条 本地气泡,如实显示、不假装已落库:本地 id 用帧里已有的稳定键(IM 消息号,其次时间字面量), 同一个键第二次到达(重推 / 重放)也只是 upsert,不会成两个气泡。KDoc 写明它不在历史里、 刷新 / 重开后不会再有。 无 id 且无正文(帧里没有任何可显示的东西)才整帧跳过 —— 那一条画的是空气泡,只会让人以为出了别的事。 五、抽屉红点(复用 W49 那条路,不新写取数路径) 服务端不为这条路补 message_persisted ⇒ W49 的「非激活会话落新消息 ⇒ 节流静默重拉」不会被触发, 主会话的红点会停在她开口之前。SessionsViewModel 用同一个 persistedRoute 把这一帧接进同一条路 (复用 requestSilentRefresh 与既有的 InactiveSessionMessage 成因,不动节流判据本身): 停在别的会话 ⇒ 节流静默重拉一次(红点亮);正看着主会话 ⇒ 与 message_persisted 同一档 「看到即已读」,免得抽屉为她刚说的话亮一个红点。无 id 那一档不拉(历史里没有行,未读面上无事可做)。 测试 - 新增 WsEventProactiveFrameTest:真实帧形状(id / createdAt / RFC3339 的 timestamp)、数字形状容忍、 落库失败那一档、以及「帧壳没有 sessionId ⇒ 会话闸对每个会话的 VM 都放行」这条事实。 - 新增 ProactiveIngestTest:归属(次级会话不并入、id 按等值比)、身份(有 id = 已落库行)、 退化(本地气泡 / 空帧跳过)、不重复(重发与历史页都收敛成一行)。 - InactiveSessionPullTest 补一条钉子:她主动说的话该走拉列表还是「看到即已读」这条路。
This commit is contained in:
1 parent
d5d2feee36
commit
8b5d15bdb6
7 files changed
+501
-4
No files matched your search
@@ -0,0 +1,93 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* `proactive_message` 帧的解码(v0.72:她主动说的话**同时**是主会话历史里的一行)。
|
||||
*
|
||||
* 这块此前**整帧解不出来**,而且症状是「静默」:载荷里的 `timestamp` 服务端是 Go `time.Time`
|
||||
* ⇒ 线上是 RFC3339 **字符串**,本端却按 `Long?` 解 ⇒ 解码抛异常,被 [WsEvent.parse] 的
|
||||
* `runCatching` 吞成 `null`。帧**在成形之前就没了**,所以连「加一条 `onEvent` 分支」那种修法都
|
||||
* 碰不到它。下面第一条用的就是服务端的真实形状(`gateway.proactivePayload`)。
|
||||
*
|
||||
* 另外两条形状上的事实也钉在这里(它们各自都足以让这一帧走错):
|
||||
* - `id` / `createdAt` 与 `messageId` **不是同一个 id**:前者才是落库行(历史里那一行)的键;
|
||||
* - 帧壳**不带** `sessionId` ⇒ [isForSession] 会对**每一个**会话的 VM 放行。
|
||||
*/
|
||||
class WsEventProactiveFrameTest {
|
||||
|
||||
/**
|
||||
* 按服务端 `gateway.proactivePayload` 的**键名与类型**构造的帧(已落库那一档):`timestamp`
|
||||
* 是 Go `time.Time` ⇒ JSON 里的 RFC3339 字符串,`id` / `createdAt` 是落库行的权威值。
|
||||
*/
|
||||
private val persistedFrame = """
|
||||
{"type":"proactive_message","data":{"platform":"cyrene-backthink","channelId":"chan-1","channelType":"private","content":"记得喝水哦","contentType":"text","sender":"Cyrene","timestamp":"2026-10-06T01:20:05.123+08:00","messageId":"im-7","id":"1371","createdAt":1791212405123}}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `the persisted frame decodes with the history row keys`() {
|
||||
val event = WsEvent.parse(persistedFrame) as WsEvent.ProactiveMessage
|
||||
|
||||
assertEquals("proactive_message", event.type)
|
||||
assertEquals("记得喝水哦", event.content)
|
||||
assertEquals("cyrene-backthink", event.platform)
|
||||
assertEquals("Cyrene", event.sender)
|
||||
// 两个 id 是两个不同的东西:`id` 才是历史行的键,`messageId` 是 IM 侧的号。
|
||||
assertEquals("1371", event.id)
|
||||
assertEquals("im-7", event.messageId)
|
||||
assertEquals(1791212405123L, event.createdAt)
|
||||
// 时间戳按线上的字面量收(它只作诊断,排序用 createdAt)。
|
||||
assertEquals("2026-10-06T01:20:05.123+08:00", event.timestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a numeric timestamp is tolerated instead of killing the frame`() {
|
||||
// 一个只作诊断的键,无论线上给字符串还是数字,都不该有能力让**整帧**消失。
|
||||
val frame = """
|
||||
{"type":"proactive_message","data":{"content":"在的","timestamp":1791212405123}}
|
||||
""".trimIndent()
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.ProactiveMessage
|
||||
|
||||
assertEquals("1791212405123", event.timestamp)
|
||||
assertEquals("在的", event.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame without a persisted row still forms with both row keys absent`() {
|
||||
// 落库失败那一档:服务端**省略**这两个键(而不是给一个悬空的 id),客户端据此知道
|
||||
// 「这条只有实时帧、没有历史行」。帧本身照旧成形——它还有正文要显示。
|
||||
val frame = """
|
||||
{"type":"proactive_message","data":{"content":"想你了","sender":"Cyrene","timestamp":"2026-10-06T01:20:05+08:00"}}
|
||||
""".trimIndent()
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.ProactiveMessage
|
||||
|
||||
assertNull(event.id)
|
||||
assertNull(event.createdAt)
|
||||
assertEquals("想你了", event.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the frame shell has no session id so the session gate lets every vm through`() {
|
||||
// 这是「必须自己钉死主会话」的根据:帧壳不带 sessionId,而 isForSession 把空值当
|
||||
// 「无法判定」**放行** ⇒ 不自己判归属,这一句话会在每个次级会话里各冒一条。
|
||||
val event = WsEvent.parse(persistedFrame) as WsEvent.ProactiveMessage
|
||||
|
||||
assertNull(event.sessionId)
|
||||
assertTrue(event.isForSession("web"))
|
||||
assertTrue(event.isForSession("web:other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty frame decodes to an empty message rather than throwing`() {
|
||||
// 载荷缺席(服务端漂移)时也不抛:整帧留下、正文为空,上层按「没有可显示的东西」跳过。
|
||||
val event = WsEvent.parse("""{"type":"proactive_message"}""") as WsEvent.ProactiveMessage
|
||||
|
||||
assertEquals("", event.content)
|
||||
assertNull(event.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.repository.mergeMessages
|
||||
import top.yeij.cyrene.data.ws.WsEvent
|
||||
|
||||
/**
|
||||
* 「她主动说的话」该怎么进本端(W50)。
|
||||
*
|
||||
* 判据全在纯函数 [proactiveIngestFor] 里,这里钉住四条**每一条错了都肉眼可见**的边界:
|
||||
* 1. **归属**:只进主会话。帧壳没有 `sessionId`(`isForSession` 因此对每个会话的 VM 放行),
|
||||
* 所以「次级会话不并入」这条必须由我们自己成立——不成立就是她一句话在每个会话里各冒一条。
|
||||
* 2. **身份**:带落库行 `id` ⇒ 按**已落库行**并入(不是 UI 气泡);这正是「刷新后她的话还在」的根据。
|
||||
* 3. **退化**:不带 `id`(服务端落库失败)⇒ 只画一条本地气泡;帧里什么可显示的都没有时不画空气泡。
|
||||
* 4. **不重复**:同一句话(实时帧 + 重连重发 + 后来的历史页)按 `id` upsert,永远只有一行。
|
||||
*/
|
||||
class ProactiveIngestTest {
|
||||
|
||||
private fun frame(
|
||||
content: String = "记得喝水哦",
|
||||
id: String? = "1371",
|
||||
createdAt: Long? = 1_791_212_405_123L,
|
||||
messageId: String? = "im-7",
|
||||
timestamp: String? = "2026-10-06T01:20:05.123+08:00",
|
||||
platform: String? = "cyrene-backthink",
|
||||
) = WsEvent.ProactiveMessage(
|
||||
platform = platform,
|
||||
channelId = "chan-1",
|
||||
channelType = "private",
|
||||
content = content,
|
||||
contentType = "text",
|
||||
sender = "Cyrene",
|
||||
timestamp = timestamp,
|
||||
messageId = messageId,
|
||||
id = id,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
// —— 归属:只进主会话 ——
|
||||
|
||||
@Test
|
||||
fun `the main session folds the frame in as a persisted assistant row`() {
|
||||
val ingest = proactiveIngestFor(frame(), MAIN_SESSION_ID, nowMillis = 999L)
|
||||
|
||||
val row = (ingest as ProactiveIngest.Persisted).message
|
||||
assertEquals("1371", row.id)
|
||||
assertEquals("web", row.sessionId)
|
||||
assertEquals("assistant", row.role)
|
||||
assertEquals("记得喝水哦", row.content)
|
||||
assertEquals(1_791_212_405_123L, row.createdAt)
|
||||
// 帧里带的平台与落库行是同一个值(服务端把 msg.Platform 同时写进帧与行)。
|
||||
assertEquals("cyrene-backthink", row.platform)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a secondary session never folds the frame in`() {
|
||||
// 帧壳没有 sessionId(服务端不设)⇒ isForSession 会把这一帧放行给每一个会话的 VM。
|
||||
// 不在这里挡住,她主动说的话就会在每个次级会话里都冒一条——比看不到更糟。
|
||||
assertEquals(
|
||||
ProactiveIngest.Skip,
|
||||
proactiveIngestFor(frame(), "web:7f3a", nowMillis = 999L),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session ids are compared verbatim, not by prefix`() {
|
||||
// 主会话 `web` 与次级会话 `web:xxx` 是两个会话(前缀相同):一条她的话不该进后者。
|
||||
assertEquals(ProactiveIngest.Skip, proactiveIngestFor(frame(), "web:1", nowMillis = 999L))
|
||||
assertTrue(proactiveIngestFor(frame(), "web", nowMillis = 999L) is ProactiveIngest.Persisted)
|
||||
}
|
||||
|
||||
// —— 身份:有 id = 已落库行 ——
|
||||
|
||||
@Test
|
||||
fun `a missing createdAt falls back to now and is corrected by the next history pull`() {
|
||||
// 两个键本该一起出现;只缺时间戳时按「现在」排在最末(帧是刚到的,那正是它该在的位置),
|
||||
// 而服务端那一行真的存在 ⇒ 下一次拉历史会用同一个 id 把它连同真时间戳一起覆盖回来。
|
||||
val ingest = proactiveIngestFor(frame(createdAt = null), MAIN_SESSION_ID, nowMillis = 4_242L)
|
||||
|
||||
assertEquals(4_242L, (ingest as ProactiveIngest.Persisted).message.createdAt)
|
||||
assertEquals("1371", ingest.message.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero createdAt is treated as absent`() {
|
||||
// 服务端零值 / 客户端解不出来都会走到这里:`0` 不是「1970 年」,是「没有这个键」。
|
||||
val ingest = proactiveIngestFor(frame(createdAt = 0L), MAIN_SESSION_ID, nowMillis = 7L)
|
||||
|
||||
assertEquals(7L, (ingest as ProactiveIngest.Persisted).message.createdAt)
|
||||
}
|
||||
|
||||
// —— 退化:落库失败那一档 ——
|
||||
|
||||
@Test
|
||||
fun `without a row id the frame degrades to a local-only bubble`() {
|
||||
val ingest = proactiveIngestFor(
|
||||
frame(id = null, createdAt = null, messageId = "im-9"),
|
||||
MAIN_SESSION_ID,
|
||||
nowMillis = 1_234L,
|
||||
)
|
||||
|
||||
val bubble = (ingest as ProactiveIngest.Bubble).message
|
||||
// 绝不能拿 `messageId` 当历史行的键(它是另一个 id,永远对不上历史)——本地的键是自铸的。
|
||||
assertEquals("proactive-local:im-9", bubble.id)
|
||||
assertEquals("assistant", bubble.role)
|
||||
assertEquals("web", bubble.sessionId)
|
||||
assertEquals("记得喝水哦", bubble.content)
|
||||
assertEquals(1_234L, bubble.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a redelivered bubble-less frame does not turn into two bubbles`() {
|
||||
// 同一帧再投一次(服务端重推 / 客户端重放)不该变成两个气泡:本地键取自帧里已有的稳定键。
|
||||
val first = proactiveIngestFor(frame(id = null, createdAt = null), MAIN_SESSION_ID, 1L)
|
||||
val again = proactiveIngestFor(frame(id = null, createdAt = null), MAIN_SESSION_ID, 2L)
|
||||
|
||||
val a = (first as ProactiveIngest.Bubble).message
|
||||
val b = (again as ProactiveIngest.Bubble).message
|
||||
assertEquals(a.id, b.id)
|
||||
|
||||
val merged = mergeMessages(listOf(a), listOf(b))
|
||||
assertEquals(1, merged.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank local id is treated as absent`() {
|
||||
// 形状漂移(空串)与「没有这个键」必须是同一个结论,否则会并入一行空 id 的历史行。
|
||||
val ingest = proactiveIngestFor(frame(id = " "), MAIN_SESSION_ID, nowMillis = 8L)
|
||||
|
||||
assertTrue(ingest is ProactiveIngest.Bubble)
|
||||
assertEquals("proactive-local:im-7", (ingest as ProactiveIngest.Bubble).message.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame with neither a row id nor any text is skipped instead of drawn empty`() {
|
||||
// 落库失败的空帧:画一条空气泡只会让人以为出了别的事(它没有任何可显示的内容)。
|
||||
assertEquals(
|
||||
ProactiveIngest.Skip,
|
||||
proactiveIngestFor(frame(content = " ", id = null, createdAt = null), MAIN_SESSION_ID, 9L),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 不重复:按 id upsert ——
|
||||
|
||||
@Test
|
||||
fun `the live frame and the same row from history stay one message`() {
|
||||
val live = (proactiveIngestFor(frame(), MAIN_SESSION_ID, nowMillis = 1L) as ProactiveIngest.Persisted)
|
||||
.message
|
||||
// 随后到达的历史页里是同一行(服务端 id 相同),内容与时间戳更完整。
|
||||
val fromHistory = HistoryMessageDto(
|
||||
id = "1371",
|
||||
sessionId = "web",
|
||||
role = "assistant",
|
||||
content = "记得喝水哦",
|
||||
createdAt = 1_791_212_405_123L,
|
||||
)
|
||||
|
||||
val merged = mergeMessages(listOf(live), listOf(fromHistory))
|
||||
|
||||
assertEquals(1, merged.size)
|
||||
assertEquals("1371", merged.single().id)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
|
||||
/**
|
||||
* 非激活会话的新消息 ⇒ 节流地静默重拉(W49 修的 S6:「停在主会话时次级会话来了新消息,
|
||||
@@ -55,6 +56,24 @@ class InactiveSessionPullTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `her proactive words pull the list while another session is open`() {
|
||||
// W50:`proactive_message` 帧壳**不带** `sessionId`(服务端构造这一帧时没设它),归属由
|
||||
// 客户端自己钉——她主动说的话恒落**主会话**(服务端 `history.MainSessionID`)。
|
||||
// 而这条路服务端**不补** `message_persisted`(那条只有 IM 路由会广播)⇒ W49 那条重拉
|
||||
// 不会被它触发,抽屉里主会话的红点会停在她开口之前。SessionsViewModel 用同一个
|
||||
// `persistedRoute` 把这一帧接进同一条路(不新写取数路径),这两条断言就是那处接线的前提:
|
||||
// 停在次级会话 ⇒ 该拉;正看着主会话 ⇒ 不拉(她的话已经在聊天流里,看到即已读)。
|
||||
assertEquals(
|
||||
PersistedRoute.InactiveSession,
|
||||
persistedRoute(sessionId = SessionSummaryDto.MAIN_SESSION_ID, activeSessionId = "web:1"),
|
||||
)
|
||||
assertEquals(
|
||||
PersistedRoute.ActiveSession,
|
||||
persistedRoute(sessionId = SessionSummaryDto.MAIN_SESSION_ID, activeSessionId = "web"),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 节流:该拉的那一刻拉,窗口内不重复拉 ——
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user