From 87442e1a92712ea030b3d9d5df4728ef7fc5e4b9 Mon Sep 17 00:00:00 2001 From: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:54:52 +0200 Subject: [PATCH] fix(chat): deduplicate message ids (#810) --- .../hermescontrol/ui/chat/ChatViewModel.kt | 24 ++++++++++------ .../ui/chat/ChatWsEventReducer.kt | 12 +++++--- .../ui/chat/ChatViewModelTest.kt | 10 +++++++ .../ui/chat/ChatWsEventReducerTest.kt | 28 +++++++++++++++++++ 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index ae24bb16..a03114c9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -154,25 +154,27 @@ internal fun mergeTranscriptWithLive( restMessages: List, currentMessages: List, ): List { + val dedupedRest = restMessages.dedupeById() + val dedupedCurrent = currentMessages.dedupeById() // User rows can carry live/cache-only metadata (for example whether the // bubble continues the active turn). Keep that richer copy when REST // returns the same logical row. - val currentUsers = currentMessages.filter { it.role == MessageRole.USER }.toMutableList() + val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList() val mergedRest = - restMessages.map { rest -> + dedupedRest.map { rest -> if (rest.role != MessageRole.USER) return@map rest val matchIndex = currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 } ?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) } if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest } - val restIds = restMessages.map { it.id }.toSet() + val restIds = dedupedRest.map { it.id }.toSet() val liveTail = - currentMessages.filter { old -> - old.id !in restIds && restMessages.none { sameLogicalMessage(it, old) } + dedupedCurrent.filter { old -> + old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) } } - if (liveTail.isEmpty()) return mergedRest - return (mergedRest + liveTail).sortedBy { it.timestamp } + if (liveTail.isEmpty()) return mergedRest.dedupeById() + return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp } } /** Merge a REST page without matching it against already-settled transcript rows. */ @@ -186,8 +188,10 @@ internal fun mergeIncrementalTranscriptPage( currentMessages.indexOfLast { message -> serverMessageIndex(message.id, sessionId)?.let { it < offset } == true } - return currentMessages.take(settledEnd + 1) + - mergeTranscriptWithLive(restMessages, currentMessages.drop(settledEnd + 1)) + return ( + currentMessages.take(settledEnd + 1) + + mergeTranscriptWithLive(restMessages, currentMessages.drop(settledEnd + 1)) + ).dedupeById() } private fun serverMessageIndex( @@ -195,6 +199,8 @@ private fun serverMessageIndex( sessionId: String, ): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull() +internal fun List.dedupeById(): List = associateBy { it.id }.values.toList() + data class ChatUiState( val messages: List = emptyList(), val currentSessionId: String? = null, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 99172aa1..40c88792 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -8,6 +8,10 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +private fun List.upsertById(message: ChatMessage): List { + return (this + message).dedupeById() +} + /** * Pure state reducer for WebSocket events. * @@ -168,7 +172,7 @@ object ChatWsEventReducer { val finalized = streamingState.streamingMessage.copy(isStreaming = false) orphan = finalized state.copy( - messages = state.messages + finalized, + messages = state.messages.upsertById(finalized), isAgentTyping = true, subagentIndicators = cleanedSubagents, ) @@ -319,7 +323,7 @@ object ChatWsEventReducer { return ReducerResult( state = state.copy( - messages = state.messages + msg, + messages = state.messages.upsertById(msg), isAgentTyping = false, ), effects = effects, @@ -358,7 +362,7 @@ object ChatWsEventReducer { return ReducerResult( state = state.copy( - messages = state.messages + msg, + messages = state.messages.upsertById(msg), isAgentTyping = false, ), effects = effects, @@ -401,7 +405,7 @@ object ChatWsEventReducer { ) orphanToPersist = finalized state.copy( - messages = state.messages + finalized, + messages = state.messages.upsertById(finalized), ) } else { state diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 40101bb2..3dcb8fe5 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -68,6 +68,16 @@ class ChatViewModelTest { /** Counter used to generate unique WS request IDs. */ private var reqCount = 0 + @Test + fun mergeTranscriptWithLive_collapsesDuplicateIdsKeepingLatestMessage() { + val stale = ChatMessage(id = "duplicate-id", role = MessageRole.ASSISTANT, content = "stale") + val latest = stale.copy(content = "latest") + + val merged = mergeTranscriptWithLive(emptyList(), listOf(stale, latest)) + + assertEquals(listOf(latest), merged) + } + @Before fun setUp() { Dispatchers.setMain(testDispatcher) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index 86982a78..03322aba 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -377,6 +377,34 @@ class ChatWsEventReducerTest { assertEquals("", persistEffect?.message?.reasoningText) } + @Test + fun testMessageComplete_replacesExistingStreamingMessageWithSameId() { + val streaming = + ChatMessage( + id = "037cae0c-494e-4a36-862f-3f3e8a235950", + role = MessageRole.ASSISTANT, + content = "partial", + isStreaming = true, + ) + val result = + ChatWsEventReducer.reduce( + state = + ChatUiState( + messages = + listOf( + streaming.copy(content = "stale", isStreaming = false), + streaming.copy(isStreaming = false), + ), + ), + streamingState = StreamingState(streamingMessage = streaming), + event = WsEvent.MessageComplete(text = "complete", sessionId = "session-1"), + currentSessionId = "session-1", + ) + + assertEquals(1, result.state.messages.size) + assertEquals("complete", result.state.messages.single().content) + } + @Test fun testMessageStart_prunesCompletedSubagents() { val completedSubagent =