From 5acf914caba0b8c118536aa44212bf4467508608 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:51:32 +0300 Subject: [PATCH] fix(chat): keep local notices in transcript order --- .../ui/chat/ChatTranscriptMerge.kt | 62 +++++++++++++-- .../ui/chat/ChatPagingMergeTest.kt | 75 +++++++++++++++++++ 2 files changed, 129 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt index 91ab94be..441728ce 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt @@ -337,10 +337,15 @@ internal fun mergeCachedTranscriptPage( displayKind = message.displayKind ?: match.displayKind, ) }.toMap() + val resolvedOrders = + incoming.indices + .mapNotNull { index -> + matches[index]?.id?.let { id -> incoming[index].canonicalOrder?.let { id to it } } + }.toMap() return ( current.map { replacements[it.id] ?: it } + incoming.filterIndexed { index, _ -> matches[index] == null } - ).dedupeById().inTranscriptOrder().reconcileReasoningRows() + ).dedupeById().inTranscriptOrder(current, resolvedOrders).reconcileReasoningRows() } /** Merge one page with the current snapshot without consuming repeated results more than once. */ @@ -398,15 +403,56 @@ internal fun mergeTranscriptWithLive( // copy is also present. Fold that now-confirmed echo without changing the live key. // The page has already consumed its content matches; consuming another would // collapse a separate repeated occurrence. Only confirmed identities can fold here. - return dedupeCachedMessages(transcript.inTranscriptOrder(), confirmedOnly = true).reconcileReasoningRows() + val resolvedOrders = + incoming.indices + .mapNotNull { index -> + matches[index]?.id?.let { id -> incoming[index].canonicalOrder?.let { id to it } } + }.toMap() + return dedupeCachedMessages(transcript.inTranscriptOrder(current, resolvedOrders), confirmedOnly = true) + .reconcileReasoningRows() } -/** Numeric canonical history first, then unconfirmed local insertion order; clocks never sort a transcript. */ -private fun List.inTranscriptOrder(): List = - sortedWith( - compareBy { it.canonicalOrder == null } - .thenBy { it.canonicalOrder ?: it.localOrder ?: Long.MAX_VALUE }, - ) +/** Keep server order; place local notices after their last preceding confirmed message, not at the transcript tail. */ +private fun List.inTranscriptOrder( + previous: List, + resolvedOrders: Map, +): List { + val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L + var precedingCanonical: Long? = null + var hasPendingPredecessor = false + var pendingLocalOrder: Long? = null + val noticeAnchors = mutableMapOf() + val pendingOrderByNotice = mutableMapOf() + previous.forEach { message -> + val order = resolvedOrders[message.id] ?: message.canonicalOrder + if (order != null) { + precedingCanonical = order + hasPendingPredecessor = false + pendingLocalOrder = null + } else if (message.role != MessageRole.SYSTEM) { + hasPendingPredecessor = true + pendingLocalOrder = message.localOrder + } else { + noticeAnchors[message.id] = + if (hasPendingPredecessor) { + Long.MAX_VALUE + } else { + precedingCanonical?.takeIf { it >= 0L } + ?: latestCanonical + } + if (hasPendingPredecessor) pendingOrderByNotice[message.id] = pendingLocalOrder ?: Long.MAX_VALUE + } + } + val previousIndices = previous.withIndex().associate { it.value.id to it.index } + return withIndex() + .sortedWith( + compareBy> { + it.value.canonicalOrder ?: noticeAnchors[it.value.id] ?: Long.MAX_VALUE + }.thenBy { if (noticeAnchors[it.value.id]?.let { anchor -> anchor != Long.MAX_VALUE } == true) 1 else 0 } + .thenBy { it.value.localOrder ?: pendingOrderByNotice[it.value.id] ?: Long.MAX_VALUE } + .thenBy { previousIndices[it.value.id] ?: it.index }, + ).map { it.value } +} internal fun ChatMessage.isSessionStartMarker(): Boolean = role == MessageRole.SYSTEM && (content == "Session created" || content == "Session branched") diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt index 022b1360..3f77240f 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt @@ -673,6 +673,81 @@ class ChatPagingMergeTest { assertEquals("Here is the Python script", merged[2].content) } + @Test + fun localNoticesKeepTheirPositionAcrossRepeatedTranscriptSyncs() { + val first = ChatMessage(id = "rest-s-1", role = MessageRole.USER, content = "hello") + val answer = ChatMessage(id = "rest-s-2", role = MessageRole.ASSISTANT, content = "hi") + val resumed = ChatMessage(id = "resume", role = MessageRole.SYSTEM, content = "Session resumed") + val review = + ChatMessage(id = "review", role = MessageRole.SYSTEM, content = "💾 Self-improvement review: skill updated") + val next = ChatMessage(id = "rest-s-3", role = MessageRole.USER, content = "next") + val reply = ChatMessage(id = "rest-s-4", role = MessageRole.ASSISTANT, content = "done") + + val afterResume = mergeTranscriptWithLive(listOf(first, answer), listOf(first, answer, resumed)) + assertEquals(listOf("rest-s-1", "rest-s-2", "resume"), afterResume.map { it.id }) + val afterReview = mergeTranscriptWithLive(listOf(first, answer), afterResume + review) + assertEquals(listOf("rest-s-1", "rest-s-2", "resume", "review"), afterReview.map { it.id }) + val afterNextTurn = mergeTranscriptWithLive(listOf(next, reply), afterReview) + assertEquals( + listOf("rest-s-1", "rest-s-2", "resume", "review", "rest-s-3", "rest-s-4"), + afterNextTurn.map { it.id }, + ) + assertEquals(afterNextTurn, mergeTranscriptWithLive(listOf(next, reply), afterNextTurn)) + assertEquals( + afterNextTurn.map { it.id }, + mergeCachedTranscriptPage(listOf(first, answer, next, reply), afterNextTurn).map { it.id }, + ) + } + + @Test + fun localNoticeOnColdResumeFollowsHydratedHistoryButNotFutureReplies() { + val resumed = ChatMessage(id = "resume", role = MessageRole.SYSTEM, content = "Session resumed") + val prior = ChatMessage(id = "rest-s-1", role = MessageRole.USER, content = "earlier") + val later = ChatMessage(id = "rest-s-2", role = MessageRole.ASSISTANT, content = "later") + val created = ChatMessage(id = "created", role = MessageRole.SYSTEM, content = "Session created") + val hydrated = mergeTranscriptWithLive(listOf(prior), listOf(created, resumed)) + assertEquals(listOf("created", "rest-s-1", "resume"), hydrated.map { it.id }) + val synced = mergeTranscriptWithLive(listOf(later), hydrated) + assertEquals(listOf("created", "rest-s-1", "resume", "rest-s-2"), synced.map { it.id }) + } + + @Test + fun unconfirmedUserAndAssistantRemainAfterCanonicalHistory() { + val user = ChatMessage(id = "uuid-user", role = MessageRole.USER, content = "pending") + val assistant = ChatMessage(id = "uuid-assistant", role = MessageRole.ASSISTANT, content = "streaming") + val canonical = ChatMessage(id = "rest-s-1", role = MessageRole.USER, content = "older") + val merged = mergeTranscriptWithLive(listOf(canonical), listOf(user, assistant)) + assertEquals(listOf("rest-s-1", "uuid-user", "uuid-assistant"), merged.map { it.id }) + } + + @Test + fun olderPageDoesNotMoveLocalNoticeOrReverseCanonicalRows() { + val old = ChatMessage(id = "rest-s-1", role = MessageRole.USER, content = "old") + val current = ChatMessage(id = "rest-s-3", role = MessageRole.ASSISTANT, content = "current") + val notice = ChatMessage(id = "notice", role = MessageRole.SYSTEM, content = "Session interrupted") + val next = ChatMessage(id = "rest-s-4", role = MessageRole.USER, content = "next") + + val paged = mergeTranscriptWithLive(listOf(old), listOf(current, notice), chronological = false) + assertEquals(listOf("rest-s-1", "rest-s-3", "notice"), paged.map { it.id }) + val synced = mergeTranscriptWithLive(listOf(next), paged) + assertEquals(listOf("rest-s-1", "rest-s-3", "notice", "rest-s-4"), synced.map { it.id }) + } + + @Test + fun reviewNoticeStaysAfterAssistantWhenItsRestEchoArrivesLater() { + val user = ChatMessage(id = "rest-s-1", role = MessageRole.USER, content = "prompt") + val liveReply = ChatMessage(id = "live-reply", role = MessageRole.ASSISTANT, content = "answer") + val review = ChatMessage(id = "review", role = MessageRole.SYSTEM, content = "💾 Self-improvement review") + val beforeEcho = mergeTranscriptWithLive(listOf(user), listOf(user, liveReply, review), preserveLiveIds = true) + assertEquals(listOf("rest-s-1", "live-reply", "review"), beforeEcho.map { it.id }) + val restReply = liveReply.copy(id = "rest-s-2") + val afterEcho = mergeTranscriptWithLive(listOf(restReply), beforeEcho, preserveLiveIds = true) + assertEquals(listOf("rest-s-1", "live-reply", "review"), afterEcho.map { it.id }) + val next = ChatMessage(id = "rest-s-3", role = MessageRole.USER, content = "next") + val synced = mergeTranscriptWithLive(listOf(next), afterEcho) + assertEquals(listOf("rest-s-1", "live-reply", "review", "rest-s-3"), synced.map { it.id }) + } + @Test fun sessionCreatedMarkerStaysAtStartOfTranscript() { val sessionCreated =