fix(chat): WS history 帧按会话过滤,修次级会话串进主会话消息

WS 首帧 history 是**主会话**最近 50 条的回放,但连接是全局的、帧会被投给任意
ChatViewModel;onEvent 此前把它**原样**并进当前消息列表,导致进次级会话会看到
主会话的消息混在一起——是数据串台,不是显示问题。

- data/repository/PagedMessages.kt:新增纯函数 historyForSession(messages, sessionId),
  按**消息自身的 sessionId** 过滤;sessionId 为空的帧视为「无法判定」放行,
  不因缺一个字段就整段丢掉历史(保持既有行为)
- ui/chat/ChatViewModel.kt:history 分支先收敛到本会话再合并

为什么按 sessionId 而不是「只有主会话才收 history」:前者更耐将来——
服务端哪天开始给次级会话回放,这里无需改动。

测试:178 全绿(175 → +3),新增三条:主会话回放不得泄漏进次级会话、
缺 sessionId 的帧放行、过滤不修改入参。
This commit is contained in:
AskaEth committed 2026-10-04 14:05:38 +08:00
1 parent c09a21136c
commit 2a49c25e1b
3 files changed
+56 -1

No files matched your search

@@ -38,3 +38,21 @@ fun mergeMessages(
incoming.forEach { byId[it.id] = it }
return byId.values.sortedWith(MessageOrder)
}
/**
* 把一帧 `history` 收敛到**本会话**。
*
* WS 首帧 `history` 是**主会话**最近 50 条的回放,但连接是全局的、帧会被投给任意
* `ChatViewModel`——不过滤就会把主会话的消息混进次级会话。
*
* 判定以**消息自身的 `sessionId`** 为准,而不是「只有主会话才收 history」:前者更耐将来,
* 服务端哪天开始给次级会话回放,这里无需改动。
*
* `sessionId` 为空的帧视为「无法判定」**放行**——不因缺一个字段就整段丢掉历史(保持既有行为)。
*
* 纯函数,不修改入参。
*/
fun historyForSession(
messages: List<HistoryMessageDto>,
sessionId: String,
): List<HistoryMessageDto> = messages.filter { it.sessionId.isBlank() || it.sessionId == sessionId }
@@ -11,6 +11,7 @@ import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.historyForSession
import top.yeij.cyrene.data.repository.mergeMessages
import top.yeij.cyrene.data.ws.ChatSocket
import top.yeij.cyrene.data.ws.WsEvent
@@ -173,7 +174,9 @@ class ChatViewModel(
private fun onEvent(event: WsEvent) {
when (event) {
is WsEvent.History -> {
val incoming = event.messages
// 这一帧是**主会话**的回放,却会被投给任意 ChatViewModel——必须先收敛到本会话,
// 否则进次级会话会看到主会话的消息混进来。
val incoming = historyForSession(event.messages, sessionId)
if (incoming.isEmpty()) {
_state.update { it.copy(loading = false) }
} else {
@@ -85,4 +85,38 @@ class MessageMergeTest {
assertEquals(listOf("m2", "m1"), idsOf(existing))
assertEquals(listOf("m3"), idsOf(incoming))
}
private fun msgIn(sessionId: String, id: String) = HistoryMessageDto(
id = id,
sessionId = sessionId,
role = "user",
content = id,
createdAt = 0L,
)
@Test
fun `the main session's history replay cannot leak into a sub session`() {
// 真实缺陷:WS 首帧 history 是主会话的回放,却会被并进每一个 ChatViewModel。
val replay = listOf(msgIn("web", "m1"), msgIn("web:abc", "m2"))
assertEquals(listOf("m2"), idsOf(historyForSession(replay, "web:abc")))
assertEquals(listOf("m1"), idsOf(historyForSession(replay, "web")))
}
@Test
fun `a frame without a sessionId is kept rather than dropping history`() {
// 缺字段时无法判定归属,放行以保持既有行为——不因缺一个字段整段丢掉历史。
val replay = listOf(msgIn("", "m1"))
assertEquals(listOf("m1"), idsOf(historyForSession(replay, "web:abc")))
}
@Test
fun `history filtering does not mutate the input`() {
val replay = listOf(msgIn("web", "m1"), msgIn("web:abc", "m2"))
historyForSession(replay, "web")
assertEquals(listOf("m1", "m2"), idsOf(replay))
}
}