feat(notify): W57 —— 「她主动说的话 ⇒ 弹通知」的帧消费点提到进程级

改了什么
- 新增 app/ProactiveFrameIntake:订 ChatSocket.events,把两类帧(proactive_message、
  message_persisted{proactive:true})折成四个入参交给 ProactiveNotifier。纯函数
  proactiveFrameOf 管「哪些帧算她主动说的话、每个入参取自帧上的哪个键」。构造即订阅,
  刻意不提供 start/stop。
- AppContainer:新增 proactiveFrameIntake(与 chatSocket 同一层,进程级)。
- SessionsViewModel:删掉 onEvent 里那两处转交 proactiveNotifier 的调用,本类不再判断
  通知的任何一件事(未读、「谁在跑」原样不动)。
- AppPresence / ProactiveNotifier 的 KDoc 跟着改:谁在读「在不在前台」、谁在喂帧。

为什么
改前这份订阅挂在 SessionsViewModel 的 viewModelScope 上。连接本身是进程级的
(ChatSocket 自带 scope,只有 close() 取消它,而 SessionsViewModel.onCleared 刻意不关),
所以从最近任务把 Activity 划掉之后连接照旧收帧——但 events 是 replay=0 的 SharedFlow,
没有订阅者的 tryEmit 当场丢掉:她的话进了连接、然后没了,通知永远不弹。这是 W53N 登记
的那一格缺口。

边界
- 判据与去重一个字节没动(仍在 ProactiveNotifier 的纯逻辑里)。
- 连接所有权没动:仍然只在登录态那一处 connect/close。
- 不是「新起一条连接」,只是把帧的消费点从 Activity 级提到进程级;VM 里那两处转交是
  删而不是留(留两处等于靠去重去遮一次重复消费,且「谁在喂通知」会有两个答案)。
- deliver 里接住落地异常:这条订阅不会再被重建,抛出去就是进程崩溃 + 通知整个哑掉。

验收
assembleDebug 通过;testDebugUnitTest 全量 113 suites / 1370 tests / 0 failures /
0 errors(基线 112/1361,新增的 9 条全在 ProactiveFrameIntakeTest)。
This commit is contained in:
AskaEth committed 2026-10-06 22:21:26 +08:00
1 parent 6de2f7656a
commit 0e03eba6a4
6 files changed
+352 -38

No files matched your search

@@ -0,0 +1,182 @@
package top.yeij.cyrene.app
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
import top.yeij.cyrene.core.notify.AppPresence
import top.yeij.cyrene.core.notify.ProactiveNotifier
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.ws.WsEvent
/**
* 「她主动说的话」在**帧这一层**的消费点(W57)。
*
* 这里钉的是**接线**,不是判据(判据与去重仍在 `ProactiveNotifierTest`):
* 1. **它不需要任何 Activity 活着就能收帧**——改前那份订阅挂在 `SessionsViewModel` 的
* `viewModelScope` 上,从最近任务划掉 Activity 就没了,而 `ChatSocket.events` 是 `replay = 0`
* 的 `SharedFlow` ⇒ 没有订阅者的帧**当场丢掉**。本类的第一条测试就是那个 bug 的现场;
* 2. 哪些帧算「她主动说的话」、四个入参各取帧上的哪个键([proactiveFrameOf])——错在这两个方向
* 都是静默的(她开口了却不弹、普通消息也弹),所以逐键钉住;
* 3. 判据与去重在新的消费点上**没有被放松**:两条路仍只弹一次、看着主会话仍不弹、别的会话仍不弹。
*
* 收集用的调度器传 `Dispatchers.Unconfined`:`launch` 与 `tryEmit` 都在当前线程上把 collect 体
* 走完,于是测试是同步的(不需要等、也不需要 `runBlocking`)。
* **测不了的**:真·进程存活(Activity 没了而进程还在)这件事只有真机能造,本层只能钉住「订阅不挂
* 在任何会随 UI 结束的 scope 上」这个可测的部分。
*/
class ProactiveFrameIntakeTest {
private val presence = AppPresence()
private val posted = mutableListOf<String>()
/** 与 `ChatSocket` 同一档的订阅方式:无 replay、有缓冲、溢出丢最旧。 */
private val events = MutableSharedFlow<WsEvent>(
replay = 0,
extraBufferCapacity = EVENT_BUFFER,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** 真实的帧(键名与类型都对服务端):实时帧带落库行 `id` + IM 号 + RFC3339 时间戳。 */
private val liveJson = """
{"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()
/** 同一条消息的另一条路:IM 桥那条路由补发的落库帧(判据键在载荷里)。 */
private val persistedProactiveJson = """
{"type":"message_persisted","sessionId":"web","data":{"proactive":true,"message":{"id":"1371","sessionId":"web","role":"assistant","content":"记得喝水哦","createdAt":1791212405123}}}
""".trimIndent()
/** 普通消息的落库广播(她正常回话):**不是**她主动说的话。 */
private val ordinaryPersistedJson = """
{"type":"message_persisted","sessionId":"web","data":{"message":{"id":"1372","sessionId":"web","role":"assistant","content":"在的","createdAt":1791212405123}}}
""".trimIndent()
/** 她主动说的、但不属主会话:绝不弹(宁可漏,也不要把别处的消息说成是她在找我说话)。 */
private val liveJsonForAnotherSession = """
{"type":"proactive_message","sessionId":"sess-abc","data":{"content":"在吗","messageId":"im-9","id":"1373"}}
""".trimIndent()
private fun intake() = ProactiveFrameIntake(
events = events,
notifier = ProactiveNotifier(presence = presence, post = { text, _ -> posted += text }),
dispatcher = Dispatchers.Unconfined,
)
private fun parse(json: String): WsEvent = requireNotNull(WsEvent.parse(json)) { "帧本身要能成形" }
// —— 这一档就是那个 bug 的现场 ——
@Test
fun `her words still notify when no activity is alive`() {
// Activity 已经从最近任务里划掉(没有任何实例在 started 档、聊天页也不在),进程被
// KeepAliveService 撑着、连接照旧在收帧。改前这里是空的:订阅随 VM 一起没了。
assertFalse("这一档的前提就是前台没有任何 Activity", presence.appInForeground)
intake()
events.tryEmit(parse(liveJson))
assertEquals(listOf("记得喝水哦"), posted)
}
@Test
fun `the notification id comes from the notifier not from this layer`() {
// 号必须按消息派生(同一条 ⇒ 同一个号、不同的句子各占一条)。这一层只转交帧,不许自己
// 造号——这里用一个把号也记下来的假 post 确认它拿到的确实是「按消息派生」的那个值。
val ids = mutableListOf<Int>()
ProactiveFrameIntake(
events = events,
notifier = ProactiveNotifier(presence = presence, post = { _, id -> ids += id }),
dispatcher = Dispatchers.Unconfined,
)
events.tryEmit(parse(liveJson))
events.tryEmit(parse(ordinaryPersistedJson))
assertEquals(1, ids.size)
}
// —— 判据与去重在新的消费点上没有被放松 ——
@Test
fun `the live frame and the persisted frame of one message notify once`() {
intake()
events.tryEmit(parse(liveJson))
// 同一条的落库帧(另一条路):认得出是同一条 ⇒ 不再弹。
events.tryEmit(parse(persistedProactiveJson))
assertEquals(listOf("记得喝水哦"), posted)
}
@Test
fun `a frame that lands on screen while the main session is open does not notify`() {
// 用户正看着她说话的那个会话:话就出现在他眼前,不打扰(判据本身在 ProactiveNotify.kt)。
presence.onActivityStarted(Any())
presence.onChatSurface(SessionSummaryDto.MAIN_SESSION_ID)
intake()
events.tryEmit(parse(liveJson))
assertEquals(emptyList<String>(), posted)
}
@Test
fun `a frame for another session never notifies`() {
intake()
events.tryEmit(parse(liveJsonForAnotherSession))
assertEquals(emptyList<String>(), posted)
}
@Test
fun `an ordinary persisted frame never notifies`() {
intake()
events.tryEmit(parse(ordinaryPersistedJson))
assertEquals(emptyList<String>(), posted)
}
// —— 哪些帧算她的话、四个入参各取自帧上的哪里 ——
@Test
fun `only the two proactive shapes are frames of her words`() {
assertNull("普通落库广播不是", proactiveFrameOf(parse(ordinaryPersistedJson)))
assertNull("流式增量不是", proactiveFrameOf(WsEvent.MessageDelta(delta = "在的", index = 0)))
assertNull("开始帧不是", proactiveFrameOf(WsEvent.MessageStart(index = 0)))
}
@Test
fun `the live frame takes the history row id and the im id apart`() {
val frame = requireNotNull(proactiveFrameOf(parse(liveJson)))
// 帧壳不带会话归属 ⇒「无法判定」,由「她主动说的话落主会话」这条事实兜住(见 ProactiveNotifier)。
assertNull(frame.sessionId)
assertEquals("1371", frame.id)
assertEquals("im-7", frame.messageId)
assertEquals("2026-10-06T01:20:05.123+08:00", frame.timestamp)
assertEquals("记得喝水哦", frame.content)
}
@Test
fun `the persisted frame has only the row id and never invents the other two`() {
val frame = requireNotNull(proactiveFrameOf(parse(persistedProactiveJson)))
assertEquals(SessionSummaryDto.MAIN_SESSION_ID, frame.sessionId)
assertEquals("1371", frame.id)
// 帧上就没有这两个键。编一个出来(比如把 `id` 也当 IM 号)就是伪造服务端键,去重会跟着漂。
assertNull(frame.messageId)
assertNull(frame.timestamp)
assertEquals("记得喝水哦", frame.content)
}
private companion object {
/** 与 `ChatSocket.EVENT_BUFFER` 同档:订阅方不设上限、发射方不阻塞。 */
const val EVENT_BUFFER = 8
}
}