Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4832a6a38 | ||
|
|
a815dd33fa | ||
|
|
cc9c75a636 | ||
|
|
43179e03c0 |
@@ -1,5 +1,17 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-07-27 — Android replayed-message identity reconciliation
|
||||
|
||||
Android history reconciliation now collapses reconnect/rejoin replays of the
|
||||
same persisted message ID before publishing the transcript to Compose. The
|
||||
latest repeated snapshot replaces the value at the message's first transcript
|
||||
position, preserving stable ordering, distinct messages, and the LazyColumn
|
||||
identity contract without index- or random-key fallbacks.
|
||||
|
||||
Focused coverage reproduces the duplicate UUID condition and verifies that the
|
||||
authoritative final content wins while every rendered message keeps a unique
|
||||
stable UI key.
|
||||
|
||||
## 2026-07-26 — Android 1.5.1 patch reconciliation
|
||||
|
||||
Android 1.5.1 reconciles the post-1.5.0 voice and chat fixes into versionCode
|
||||
|
||||
@@ -1223,6 +1223,14 @@ class ChatHandler {
|
||||
// so we can attach results back to the originating assistant message's ToolCall
|
||||
val toolResults = items.filter { it.role == "tool" }
|
||||
.associateBy { it.toolCallId }
|
||||
// A reconnect/rejoin history response can repeat a persisted message row.
|
||||
// Chat's LazyColumn renders domain ids as stable keys (via ChatMessage.uiKey),
|
||||
// so allowing both copies through would crash Compose before either copy
|
||||
// could be reconciled. A domain id identifies one persisted message: retain
|
||||
// its first transcript position while adopting the latest repeated snapshot.
|
||||
// Rows without ids remain independent, and tool/hidden rows keep their
|
||||
// separate handling above/below.
|
||||
val renderedItems = coalesceRenderedHistoryItems(items)
|
||||
|
||||
// Accumulator for media markers we find in loaded content — fired AFTER
|
||||
// the wholesale `_messages.value = ...` assignment so the ViewModel's
|
||||
@@ -1240,8 +1248,8 @@ class ChatHandler {
|
||||
// silently misses those rows, so a gateway turn's tokens/badges survived
|
||||
// only if a content match happened to cover them. See
|
||||
// [reconcileLiveIdsToServer].
|
||||
val serverItemIds = items.mapNotNullTo(HashSet()) { it.id }
|
||||
val idRemap = reconcileLiveIdsToServer(items, serverItemIds)
|
||||
val serverItemIds = renderedItems.mapNotNullTo(HashSet()) { it.id }
|
||||
val idRemap = reconcileLiveIdsToServer(renderedItems, serverItemIds)
|
||||
|
||||
// Carry CLIENT-ONLY enrichment forward across the reload, keyed by the
|
||||
// RECONCILED message id. The server transcript (MessageItem) rebuilds
|
||||
@@ -1278,7 +1286,7 @@ class ChatHandler {
|
||||
// clientOnly bubbles (same exchange, pre-sync copy).
|
||||
val syncedRealtimeTurnContents = mutableSetOf<String>()
|
||||
|
||||
val loaded = items.mapNotNull { item ->
|
||||
val loaded = renderedItems.mapNotNull { item ->
|
||||
val displayKind = item.displayKind?.trim()?.lowercase()
|
||||
if (displayKind == "hidden") return@mapNotNull null
|
||||
val role = when {
|
||||
@@ -1540,6 +1548,34 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse replayed visible history rows by their authoritative message id.
|
||||
*
|
||||
* Replacing the value at its first-seen slot preserves transcript ordering;
|
||||
* the last repeated value wins so a later, more complete snapshot is not lost.
|
||||
* Null ids cannot be proven identical and therefore remain separate rows.
|
||||
*/
|
||||
private fun coalesceRenderedHistoryItems(items: List<MessageItem>): List<MessageItem> {
|
||||
val firstSlotById = HashMap<String, Int>()
|
||||
val coalesced = ArrayList<MessageItem>(items.size)
|
||||
for (item in items) {
|
||||
if (renderedRoleOf(item) == null) continue
|
||||
val id = item.id
|
||||
if (id == null) {
|
||||
coalesced += item
|
||||
continue
|
||||
}
|
||||
val existingSlot = firstSlotById[id]
|
||||
if (existingSlot == null) {
|
||||
firstSlotById[id] = coalesced.size
|
||||
coalesced += item
|
||||
} else {
|
||||
coalesced[existingSlot] = item
|
||||
}
|
||||
}
|
||||
return coalesced
|
||||
}
|
||||
|
||||
/** One adoptable server row during id reconciliation. `taken` enforces consume-once. */
|
||||
private class ReconcileSlot(
|
||||
val serverId: String,
|
||||
|
||||
@@ -1517,6 +1517,49 @@ class ChatHandlerTest {
|
||||
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_coalescesReplayedDomainIdWithoutLosingOrderOrContent() {
|
||||
val replayedId = "2c93af28-0b0b-436b-a112-7f164cac931d"
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "user-1",
|
||||
role = "user",
|
||||
content = JsonPrimitive("question"),
|
||||
timestamp = 1.0,
|
||||
),
|
||||
MessageItem(
|
||||
id = replayedId,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("partial answer"),
|
||||
timestamp = 2.0,
|
||||
),
|
||||
MessageItem(
|
||||
id = "system-1",
|
||||
role = "system",
|
||||
content = JsonPrimitive("distinct visible content"),
|
||||
timestamp = 3.0,
|
||||
),
|
||||
// Rejoin replay of the same persisted message. The latest
|
||||
// snapshot is authoritative, but its first transcript position
|
||||
// and Compose identity must remain stable.
|
||||
MessageItem(
|
||||
id = replayedId,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("final answer"),
|
||||
timestamp = 4.0,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val messages = handler.messages.value
|
||||
assertEquals(listOf("user-1", replayedId, "system-1"), messages.map { it.id })
|
||||
assertEquals("final answer", messages[1].content)
|
||||
assertEquals("distinct visible content", messages[2].content)
|
||||
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() {
|
||||
// Once the first reload adopts the server id, subsequent reloads match by
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.3.0" apply false
|
||||
id("com.android.library") version "9.3.0" apply false
|
||||
id("com.android.application") version "9.3.1" apply false
|
||||
id("com.android.library") version "9.3.1" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" apply false
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[versions]
|
||||
appVersionName = "1.5.1"
|
||||
appVersionCode = "34"
|
||||
agp = "9.3.0"
|
||||
agp = "9.3.1"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.06.01"
|
||||
navigation-compose = "2.9.8"
|
||||
@@ -15,7 +15,7 @@ security-crypto = "1.1.0"
|
||||
tink-android = "1.23.0"
|
||||
lifecycle = "2.11.0"
|
||||
activity-compose = "1.13.0"
|
||||
browser = "1.9.0"
|
||||
browser = "1.10.0"
|
||||
appcompat = "1.7.1"
|
||||
core-ktx = "1.19.0"
|
||||
datastore = "1.2.1"
|
||||
|
||||
@@ -5,8 +5,8 @@ pluginManagement {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
plugins {
|
||||
id("com.android.application") version "9.3.0"
|
||||
id("com.android.library") version "9.3.0"
|
||||
id("com.android.application") version "9.3.1"
|
||||
id("com.android.library") version "9.3.1"
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user