fix(chat): deduplicate message ids (#810)

This commit is contained in:
AlexDev_ authored and GitHub committed 2026-08-04 19:54:52 +03:00
1 parent 356ccc98b5
commit 87442e1a92
4 files changed
+61 -13

No files matched your search

@@ -154,25 +154,27 @@ internal fun mergeTranscriptWithLive(
restMessages: List<ChatMessage>,
currentMessages: List<ChatMessage>,
): List<ChatMessage> {
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<ChatMessage>.dedupeById(): List<ChatMessage> = associateBy { it.id }.values.toList()
data class ChatUiState(
val messages: List<ChatMessage> = emptyList(),
val currentSessionId: String? = null,
@@ -8,6 +8,10 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
private fun List<ChatMessage>.upsertById(message: ChatMessage): List<ChatMessage> {
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