diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index be430480fb0..37bb3f472ee 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -265,7 +265,7 @@ class ChatController( } val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""") - val history = parseHistory(historyJson, sessionKey = key) + val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value) _messages.value = history.messages _sessionId.value = history.sessionId history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } @@ -336,7 +336,7 @@ class ChatController( try { val historyJson = session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""") - val history = parseHistory(historyJson, sessionKey = _sessionKey.value) + val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value) _messages.value = history.messages _sessionId.value = history.sessionId history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } @@ -450,7 +450,11 @@ class ChatController( } } - private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory { + private fun parseHistory( + historyJson: String, + sessionKey: String, + previousMessages: List, + ): ChatHistory { val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList()) val sid = root["sessionId"].asStringOrNull() val thinkingLevel = root["thinkingLevel"].asStringOrNull() @@ -470,7 +474,12 @@ class ChatController( ) } - return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages) + return ChatHistory( + sessionKey = sessionKey, + sessionId = sid, + thinkingLevel = thinkingLevel, + messages = reconcileMessageIds(previous = previousMessages, incoming = messages), + ) } private fun parseMessageContent(el: JsonElement): ChatMessageContent? { @@ -519,6 +528,47 @@ class ChatController( } } +internal fun reconcileMessageIds(previous: List, incoming: List): List { + if (previous.isEmpty() || incoming.isEmpty()) return incoming + + val idsByKey = LinkedHashMap>() + for (message in previous) { + val key = messageIdentityKey(message) ?: continue + idsByKey.getOrPut(key) { ArrayDeque() }.addLast(message.id) + } + + return incoming.map { message -> + val key = messageIdentityKey(message) ?: return@map message + val ids = idsByKey[key] ?: return@map message + val reusedId = ids.removeFirstOrNull() ?: return@map message + if (ids.isEmpty()) { + idsByKey.remove(key) + } + if (reusedId == message.id) return@map message + message.copy(id = reusedId) + } +} + +internal fun messageIdentityKey(message: ChatMessage): String? { + val role = message.role.trim().lowercase() + if (role.isEmpty()) return null + + val timestamp = message.timestampMs?.toString().orEmpty() + val contentFingerprint = + message.content.joinToString(separator = "\u001E") { part -> + listOf( + part.type.trim().lowercase(), + part.text?.trim().orEmpty(), + part.mimeType?.trim()?.lowercase().orEmpty(), + part.fileName?.trim().orEmpty(), + part.base64?.hashCode()?.toString().orEmpty(), + ).joinToString(separator = "\u001F") + } + + if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null + return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|") +} + private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt new file mode 100644 index 00000000000..936bd526eb8 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt @@ -0,0 +1,81 @@ +package ai.openclaw.app.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class ChatControllerMessageIdentityTest { + @Test + fun reconcileMessageIdsReusesMatchingIdsAcrossHistoryReload() { + val previous = + listOf( + ChatMessage( + id = "msg-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ChatMessage( + id = "msg-2", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hi")), + timestampMs = 2000L, + ), + ) + + val incoming = + listOf( + ChatMessage( + id = "new-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ChatMessage( + id = "new-2", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hi")), + timestampMs = 2000L, + ), + ) + + val reconciled = reconcileMessageIds(previous = previous, incoming = incoming) + + assertEquals(listOf("msg-1", "msg-2"), reconciled.map { it.id }) + } + + @Test + fun reconcileMessageIdsLeavesNewMessagesUntouched() { + val previous = + listOf( + ChatMessage( + id = "msg-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ) + + val incoming = + listOf( + ChatMessage( + id = "new-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ChatMessage( + id = "new-2", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "new reply")), + timestampMs = 3000L, + ), + ) + + val reconciled = reconcileMessageIds(previous = previous, incoming = incoming) + + assertEquals("msg-1", reconciled[0].id) + assertEquals("new-2", reconciled[1].id) + assertNotEquals(reconciled[0].id, reconciled[1].id) + } +}