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,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