From 4a034684bbe727baebf8bb472f74c08a19fd5970 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:40:20 +0300 Subject: [PATCH] feat(chat): adopt order=latest for transcript initial load and paging (issue #859) (#870) Backend now supports order=latest on GET /api/sessions/{id}/messages (offset measured back from the newest message, page returned chronologically). The mobile initial load previously computed its anchor from a possibly-stale session-list message_count, which landed the first page at the wrong position (dropped history or a duplicated tail). - HermesApiService.getSessionMessages: add @Query("order") - Initial load requests the newest page directly (order=latest, offset=0) and drops the fetchServerMessageCount offset math; legacy backends (no pagination echo) fall back to the count-based anchor - Backward paging advances the from-end offset by a full page; a short page marks the oldest boundary and stops pagination - ChatMessage stable keys under latest paging use the server's AUTOINCREMENT row id (from-end positions shift as the transcript grows and would collide across hydrations) - syncCurrentSession refetches the newest page and merges by row id with a logical-content fallback for re-keyed rows Co-authored-by: M57 --- .../data/model/SessionMessagesResponse.kt | 19 ++ .../data/remote/HermesApiService.kt | 4 + .../hermescontrol/ui/chat/ChatViewModel.kt | 149 +++++++++-- .../ui/chat/ChatViewModelTest.kt | 252 +++++++++++++++++- 4 files changed, 392 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt index 4ea4cf71..c03da55c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt @@ -9,10 +9,29 @@ data class SessionMessagesResponse( val messages: List, val offset: Int? = null, val total: Int? = null, + val pagination: PaginationInfo? = null, +) + +/** + * Echo of the backend's pagination state for GET /api/sessions/{id}/messages + * (hermes_cli/web_routers/sessions.py). Absent on legacy backends that + * predate the `order` param — its presence also proves `order=latest` was + * honored (issue #859). + */ +@Serializable +data class PaginationInfo( + val limit: Int? = null, + val offset: Int? = null, + val order: String? = null, + val returned: Int? = null, ) @Serializable data class SessionMessage( + // The gateway's AUTOINCREMENT row id (hermes_state_common.py + // messages.id) — stable, unique and never reused. Used as the chat-list + // stable key under newest-anchored paging (issue #859). + val id: Int? = null, val role: String? = null, val content: JsonElement? = null, val timestamp: JsonElement? = null, diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 6d5c002c..9cc57a22 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -187,6 +187,10 @@ interface HermesApiService { @Path("id", encoded = true) sessionId: String, @Query("limit") limit: Int? = null, @Query("offset") offset: Int = 0, + // Issue #859: order=latest pages back from the newest message (offset + // measured from the end, page returned chronologically). Omitted on + // legacy backends that don't know the param — they ignore it. + @Query("order") order: String? = null, @Query("include_compacted") includeCompacted: Boolean? = true, ): Response 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 03938b86..cc5ac31b 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 @@ -425,6 +425,15 @@ class ChatViewModel( /** Show the "session gone" notice once the recovery create lands (create wipes messages). */ private var pendingGoneSessionNotice = false private var loadedMessageOffset = 0 + + /** + * True once the backend honored `order=latest` on the initial page (the + * pagination echo came back). Offsets then count BACK from the newest + * message and older pages INCREASE the offset; a full page means more + * older messages exist. False on legacy backends (no `order` param) — + * offsets stay absolute and decrease toward 0 (issue #859). + */ + private var latestPaging = false private var isSyncingMessages = false // ── Session resume recovery (desktop parity) ──────────────────────── @@ -1868,16 +1877,36 @@ class ChatViewModel( val requestSequence = ++hydrationRequestSequence activeHydrationRequestSequence = requestSequence viewModelScope.launch { - val messageCount = fetchServerMessageCount(sessionId, generation, requestSequence) + // Initial page: ask for the NEWEST page directly (order=latest — + // offset is measured back from the newest message, page returned + // chronologically; verified in hermes_state.py get_messages). + // No count-based anchor, so a stale session-list message_count can + // no longer land the page at the wrong position (issue #859). + // Legacy backends without the `order` param ignore it and echo no + // `pagination` — detect that and fall back to the count-based + // anchor so nothing regresses. + val latestResult = fetchMessagePage(sessionId, 0, MESSAGE_PAGE_SIZE, order = "latest") if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch - val offset = (messageCount - MESSAGE_PAGE_SIZE).coerceAtLeast(0) - val result = fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE) + val (result, requestedOffset) = + if (latestResult is NetworkResult.Success && latestResult.data.pagination?.order == "latest") { + latestPaging = true + latestResult to 0 + } else if (latestResult is NetworkResult.Success) { + latestPaging = false + val messageCount = fetchServerMessageCount(sessionId, generation, requestSequence) + if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch + val offset = (messageCount - MESSAGE_PAGE_SIZE).coerceAtLeast(0) + fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE) to offset + } else { + latestPaging = false + latestResult to 0 + } if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch when (result) { is NetworkResult.Success -> { // REST 200 — the gateway has the row for this session. sessionHasServerPresence = true - val serverOffset = result.data.offset ?: offset + val serverOffset = result.data.pagination?.offset ?: result.data.offset ?: requestedOffset val chatMessages = mapServerMessages(sessionId, result.data.messages.orEmpty(), serverOffset) loadedMessageOffset = serverOffset withContext(ioDispatcher) { @@ -1890,10 +1919,20 @@ class ChatViewModel( // drop live WS bubbles (running tool call, streaming // answer) the server hasn't persisted yet. Issue #771. val merged = mergeTranscriptWithLive(chatMessages, state.messages) + val hasOlder = + if (latestPaging) { + // Newest-anchored: older messages exist iff the + // page came back FULL (a short page means we hit + // the oldest boundary). + val returned = result.data.pagination?.returned ?: chatMessages.size + returned >= MESSAGE_PAGE_SIZE && chatMessages.isNotEmpty() + } else { + serverOffset > 0 && chatMessages.isNotEmpty() + } state.copy( messages = merged, isLoading = false, - hasOlderMessages = serverOffset > 0 && chatMessages.isNotEmpty(), + hasOlderMessages = hasOlder, isLoadingOlder = false, ) } @@ -1959,6 +1998,7 @@ class ChatViewModel( runtimeSessionId = null ActiveSessionHolder.clear() loadedMessageOffset = 0 + latestPaging = false isSyncingMessages = false streamingController.resetStreaming() _streamingState.value = StreamingState() @@ -2197,25 +2237,53 @@ class ChatViewModel( val state = _uiState.value val sessionId = state.currentSessionId ?: return val generation = sessionGeneration - if (!state.hasOlderMessages || state.isLoadingOlder || loadedMessageOffset <= 0) return + if (!state.hasOlderMessages || state.isLoadingOlder) return + if (!latestPaging && loadedMessageOffset <= 0) return val oldOffset = loadedMessageOffset - val newOffset = (oldOffset - MESSAGE_PAGE_SIZE).coerceAtLeast(0) - val limit = oldOffset - newOffset + // latest: offsets count BACK from the newest message, so older pages go + // UP; legacy: absolute offsets go DOWN toward 0 (issue #859). + val newOffset = + if (latestPaging) { + oldOffset + MESSAGE_PAGE_SIZE + } else { + (oldOffset - MESSAGE_PAGE_SIZE).coerceAtLeast(0) + } + // Legacy: the final page can be short; requesting a full page at a + // clamped offset OVERLAPS already-loaded rows (duplicate stable keys + // crash LazyColumn), so size the request to the real gap. Latest: + // pages are disjoint from-end ranges, so a full page never overlaps. + val limit = if (latestPaging) MESSAGE_PAGE_SIZE else oldOffset - newOffset _uiState.update { it.copy(isLoadingOlder = true) } viewModelScope.launch { - when (val result = fetchMessagePage(sessionId, newOffset, limit)) { + val result = + fetchMessagePage( + sessionId, + newOffset, + limit, + order = if (latestPaging) "latest" else null, + ) + when (result) { is NetworkResult.Success -> { if (!isCurrentSessionRequest(sessionId, generation)) return@launch - val returnedOffset = result.data.offset ?: newOffset + val returnedOffset = result.data.pagination?.offset ?: result.data.offset ?: newOffset val older = mapServerMessages(sessionId, result.data.messages.orEmpty(), returnedOffset) loadedMessageOffset = returnedOffset withContext(ioDispatcher) { repo.persistMessages(older, sessionId) } _uiState.update { current -> if (!isCurrentSessionRequest(sessionId, generation)) return@update current + val hasOlder = + if (latestPaging) { + // Full page = more older messages behind it; a + // short (or empty) page is the oldest boundary. + val returned = result.data.pagination?.returned ?: older.size + returned >= limit && older.isNotEmpty() + } else { + returnedOffset < oldOffset && older.isNotEmpty() && returnedOffset > 0 + } current.copy( messages = (older + current.messages).distinctBy { it.id }, isLoadingOlder = false, - hasOlderMessages = returnedOffset < oldOffset && older.isNotEmpty() && returnedOffset > 0, + hasOlderMessages = hasOlder, ) } } @@ -2248,15 +2316,31 @@ class ChatViewModel( return } val nextOffset = - state.messages - .mapNotNull { serverMessageIndex(it.id, sessionId) } - .maxOrNull() - ?.plus(1) - ?: loadedMessageOffset + if (latestPaging) { + // Newest-anchored paging can't compute an absolute + // "after my last row" offset (from-end offsets shift as the + // transcript grows), so refetch the newest page; the logical + // merge below keeps existing copies and only adds new rows + // (issue #859). + 0 + } else { + state.messages + .mapNotNull { serverMessageIndex(it.id, sessionId) } + .maxOrNull() + ?.plus(1) + ?: loadedMessageOffset + } isSyncingMessages = true viewModelScope.launch { try { - when (val result = fetchMessagePage(sessionId, nextOffset, MESSAGE_PAGE_SIZE)) { + val result = + fetchMessagePage( + sessionId, + nextOffset, + MESSAGE_PAGE_SIZE, + order = if (latestPaging) "latest" else null, + ) + when (result) { is NetworkResult.Success -> { if (!isCurrentSessionRequest(sessionId, generation)) return@launch val incoming = mapServerMessages(sessionId, result.data.messages.orEmpty(), nextOffset) @@ -2292,6 +2376,22 @@ class ChatViewModel( if (matchIdx != null && unmatchedIncoming[matchIdx] != null) { mergedList.add(unmatchedIncoming[matchIdx]!!) unmatchedIncoming[matchIdx] = null + } else if (latestPaging) { + // Newest-anchored paging re-keys rows by + // from-end position, so a transcript that + // grew since the last fetch shifted ids — + // match by logical content and keep the + // existing copy (issue #859). + val logicalIdx = + unmatchedIncoming.indexOfFirst { inc -> + inc != null && sameLogicalMessage(inc, existing) + } + if (logicalIdx >= 0) { + mergedList.add(existing) + unmatchedIncoming[logicalIdx] = null + } else { + mergedList.add(existing) + } } else { mergedList.add(existing) } @@ -2516,6 +2616,7 @@ class ChatViewModel( sessionId: String, offset: Int, limit: Int, + order: String? = null, ) = withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getSessionMessages( @@ -2523,6 +2624,7 @@ class ChatViewModel( limit = limit, offset = offset, includeCompacted = true, + order = order, ) } } @@ -2584,6 +2686,17 @@ class ChatViewModel( else -> MessageRole.ASSISTANT } val globalIndex = offset + index + // Issue #859: under newest-anchored paging use the server's + // AUTOINCREMENT row id as the stable key — from-end positions shift + // as the transcript grows and would collide across hydrations + // (distinctBy would silently drop the newest copy). Legacy paging + // keeps the absolute-position key its count-based sync math needs. + val restId = + if (latestPaging) { + msg.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-$globalIndex" + } else { + "rest-$sessionId-$globalIndex" + } val timestamp = msg.timestampText ?.toDoubleOrNull() @@ -2666,7 +2779,7 @@ class ChatViewModel( mapped.add( ChatMessage( - id = "rest-$sessionId-$globalIndex", + id = restId, role = role, content = finalContent, reasoningText = finalReasoning, 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 b7164488..7810d1aa 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 @@ -1388,8 +1388,24 @@ class ChatViewModelTest { retrofit2.Response.success( com.m57.hermescontrol.data.model.SessionListResponse(sessions = emptyList(), total = 0), ) - coEvery { mockApi.getSessionMessages("session-a", any(), any(), any()) } coAnswers { messagesA.await() } - coEvery { mockApi.getSessionMessages("session-b", any(), any(), any()) } coAnswers { messagesB.await() } + coEvery { + mockApi.getSessionMessages( + "session-a", + any(), + any(), + any(), + any(), + ) + } coAnswers { messagesA.await() } + coEvery { + mockApi.getSessionMessages( + "session-b", + any(), + any(), + any(), + any(), + ) + } coAnswers { messagesB.await() } val (viewModel, _) = createViewModelWithSession() val resumeRequests = mutableMapOf() @@ -1440,8 +1456,24 @@ class ChatViewModelTest { retrofit2.Response.success( com.m57.hermescontrol.data.model.SessionListResponse(sessions = emptyList(), total = 0), ) - coEvery { mockApi.getSessionMessages("session-a", any(), any(), any()) } coAnswers { messagesA.await() } - coEvery { mockApi.getSessionMessages("session-b", any(), any(), any()) } coAnswers { messagesB.await() } + coEvery { + mockApi.getSessionMessages( + "session-a", + any(), + any(), + any(), + any(), + ) + } coAnswers { messagesA.await() } + coEvery { + mockApi.getSessionMessages( + "session-b", + any(), + any(), + any(), + any(), + ) + } coAnswers { messagesB.await() } val (viewModel, _) = createViewModelWithSession() viewModel.switchSession("session-a") @@ -1669,7 +1701,7 @@ class ChatViewModelTest { ), ) coEvery { - mockApi.getSessionMessages("session-456", any(), any(), any()) + mockApi.getSessionMessages("session-456", any(), any(), any(), any()) } returns retrofit2.Response.success( com.m57.hermescontrol.data.model.SessionMessagesResponse( @@ -1679,7 +1711,7 @@ class ChatViewModelTest { } else { // Relaxed mock returns a non-success response → NetworkResult.Failure. coEvery { - mockApi.getSessionMessages("session-456", any(), any(), any()) + mockApi.getSessionMessages("session-456", any(), any(), any(), any()) } returns retrofit2.Response.error( 500, @@ -1706,7 +1738,7 @@ class ChatViewModelTest { ), ) sessionIds.forEach { sessionId -> - coEvery { mockApi.getSessionMessages(sessionId, any(), any(), any()) } returns + coEvery { mockApi.getSessionMessages(sessionId, any(), any(), any(), any()) } returns retrofit2.Response.success( com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()), ) @@ -2106,7 +2138,7 @@ class ChatViewModelTest { // requested with. val fetchedSessions = mutableListOf() coEvery { - ApiClient.hermesApi.getSessionMessages(capture(fetchedSessions), any(), any(), any()) + ApiClient.hermesApi.getSessionMessages(capture(fetchedSessions), any(), any(), any(), any()) } returns retrofit2.Response.success( com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()), @@ -2150,7 +2182,7 @@ class ChatViewModelTest { val mockApi = ApiClient.hermesApi var restCalls = 0 coEvery { - mockApi.getSessionMessages("session-456", any(), any(), any()) + mockApi.getSessionMessages("session-456", any(), any(), any(), any()) } coAnswers { val call = restCalls++ if (call < 3) { @@ -2817,7 +2849,7 @@ class ChatViewModelTest { ), ) coEvery { - mockApi.getSessionMessages("session-456", any(), any(), any()) + mockApi.getSessionMessages("session-456", any(), any(), any(), any()) } returns retrofit2.Response.success( com.m57.hermescontrol.data.model.SessionMessagesResponse( @@ -2863,10 +2895,12 @@ class ChatViewModelTest { ) var messagesCallCount = 0 coEvery { - mockApi.getSessionMessages("session-456", any(), any(), any()) + mockApi.getSessionMessages("session-456", any(), any(), any(), any()) } coAnswers { messagesCallCount += 1 - if (messagesCallCount == 1) { + // Call 1 = the order=latest probe (discarded when the legacy + // backend echoes no pagination — issue #859). + if (messagesCallCount == 2) { retrofit2.Response.success( com.m57.hermescontrol.data.model.SessionMessagesResponse( messages = @@ -2937,7 +2971,7 @@ class ChatViewModelTest { ) var messagesCallCount = 0 coEvery { - mockApi.getSessionMessages("session-456", any(), any(), any()) + mockApi.getSessionMessages("session-456", any(), any(), any(), any()) } coAnswers { messagesCallCount += 1 if (messagesCallCount == 1) { @@ -3012,7 +3046,7 @@ class ChatViewModelTest { put("status", JsonPrimitive("ok")) } coEvery { - mockApi.getSessionMessages("session-456", any(), any(), any()) + mockApi.getSessionMessages("session-456", any(), any(), any(), any()) } returns retrofit2.Response.success( com.m57.hermescontrol.data.model.SessionMessagesResponse( @@ -3036,6 +3070,196 @@ class ChatViewModelTest { assertEquals("{\"status\":\"ok\"}", messages[0].content) } + // ── Newest-anchored paging (issue #859) ─────────────────────────────── + + @Test + fun testInitialLoad_latestOrder_pagesFromNewest() = + runTest { + val (viewModel, _) = createViewModelWithSession() + + val mockApi = ApiClient.hermesApi + val captured = mutableListOf>() + coEvery { + mockApi.getSessionMessages("session-456", any(), any(), any(), any()) + } coAnswers { + captured.add(Triple(arg(2), arg(1), arg(3))) + retrofit2.Response.success( + com.m57.hermescontrol.data.model.SessionMessagesResponse( + messages = + (1..150).map { i -> + com.m57.hermescontrol.data.model.SessionMessage( + id = i, + role = "assistant", + content = JsonPrimitive("Msg $i"), + ) + }, + pagination = + com.m57.hermescontrol.data.model.PaginationInfo( + limit = 150, + offset = 0, + order = "latest", + returned = 150, + ), + ), + ) + } + + viewModel.switchSession("session-456") + advanceUntilIdle() + + // One request: the newest page at offset 0 with order=latest — + // no count-based anchor, no sessions-list fetch. + assertEquals(1, captured.size) + assertEquals(Triple(0, 150, "latest"), captured[0]) + assertTrue(viewModel.uiState.value.hasOlderMessages) + assertEquals(150, viewModel.uiState.value.messages.size) + // Stable keys come from the server row id, not the from-end position. + assertEquals("rest-session-456-150", viewModel.uiState.value.messages.last().id) + } + + @Test + fun testInitialLoad_latestOrder_shortPage_hasNoOlder() = + runTest { + val (viewModel, _) = createViewModelWithSession() + + val mockApi = ApiClient.hermesApi + coEvery { + mockApi.getSessionMessages("session-456", any(), any(), any(), any()) + } returns + retrofit2.Response.success( + com.m57.hermescontrol.data.model.SessionMessagesResponse( + messages = + listOf( + com.m57.hermescontrol.data.model.SessionMessage( + id = 1, + role = "assistant", + content = JsonPrimitive("Only Msg"), + ), + ), + pagination = + com.m57.hermescontrol.data.model.PaginationInfo( + limit = 150, + offset = 0, + order = "latest", + returned = 1, + ), + ), + ) + + viewModel.switchSession("session-456") + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.hasOlderMessages) + assertEquals(1, viewModel.uiState.value.messages.size) + } + + @Test + fun testLoadOlderMessages_latestOrder_increasesOffset() = + runTest { + val (viewModel, _) = createViewModelWithSession() + + val mockApi = ApiClient.hermesApi + val captured = mutableListOf>() + var page = 0 + coEvery { + mockApi.getSessionMessages("session-456", any(), any(), any(), any()) + } coAnswers { + captured.add(Triple(arg(2), arg(1), arg(3))) + page += 1 + val (offset, returned) = + when (page) { + 1 -> 0 to 150 + 2 -> 150 to 150 + else -> 300 to 50 + } + retrofit2.Response.success( + com.m57.hermescontrol.data.model.SessionMessagesResponse( + messages = + (1..returned).map { i -> + com.m57.hermescontrol.data.model.SessionMessage( + id = offset + i, + role = "assistant", + content = JsonPrimitive("Page $page Msg $i"), + ) + }, + pagination = + com.m57.hermescontrol.data.model.PaginationInfo( + limit = 150, + offset = offset, + order = "latest", + returned = returned, + ), + ), + ) + } + + viewModel.switchSession("session-456") + advanceUntilIdle() + assertTrue(viewModel.uiState.value.hasOlderMessages) + + // Older pages INCREASE the from-end offset, always full-size. + viewModel.loadOlderMessages() + advanceUntilIdle() + assertEquals(Triple(150, 150, "latest"), captured[1]) + assertTrue(viewModel.uiState.value.hasOlderMessages) + assertEquals(300, viewModel.uiState.value.messages.size) + + // Short final page: the oldest boundary — pagination stops. + viewModel.loadOlderMessages() + advanceUntilIdle() + assertEquals(Triple(300, 150, "latest"), captured[2]) + assertFalse(viewModel.uiState.value.hasOlderMessages) + assertEquals(350, viewModel.uiState.value.messages.size) + } + + @Test + fun testSync_latestOrder_grownTranscript_keepsNewestMessage() = + runTest { + val (viewModel, _) = createViewModelWithSession() + + val mockApi = ApiClient.hermesApi + var page = 0 + coEvery { + mockApi.getSessionMessages("session-456", any(), any(), any(), any()) + } coAnswers { + page += 1 + // Hydration serves rows 1..150; the transcript then grows by 10 + // and the sync refetches the newest page (rows 11..160). + val (from, to) = if (page == 1) 1 to 150 else 11 to 160 + retrofit2.Response.success( + com.m57.hermescontrol.data.model.SessionMessagesResponse( + messages = + (from..to).map { i -> + com.m57.hermescontrol.data.model.SessionMessage( + id = i, + role = "assistant", + content = JsonPrimitive("Msg $i"), + ) + }, + pagination = + com.m57.hermescontrol.data.model.PaginationInfo( + limit = 150, + offset = 0, + order = "latest", + returned = 150, + ), + ), + ) + } + + viewModel.switchSession("session-456") + advanceUntilIdle() + assertEquals(150, viewModel.uiState.value.messages.size) + + viewModel.syncCurrentSession() + advanceUntilIdle() + + // 160 rows: the 10 new ones appended, the existing 150 kept — + // no duplicates, no dropped newest copy (stable row-id keys). + assertEquals(160, viewModel.uiState.value.messages.size) + assertTrue(viewModel.uiState.value.messages.any { it.id == "rest-session-456-160" }) + } + // ── Attachment open (issue #724) ───────────────────────────────────── @Test