From c9bbadf5d5580325e10980301500347cb539837a Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:17:43 +0300 Subject: [PATCH 1/2] fix(chat): anchor local commands, feedback, and clarify replies in transcript (#1241) --- .../ui/chat/ChatClarifyDelegate.kt | 1 + .../hermescontrol/ui/chat/ChatListEntries.kt | 2 +- .../ui/chat/ChatTranscriptMerge.kt | 52 +++++-- .../ui/chat/fullbleed/FullBleedTurns.kt | 3 +- .../ui/chat/ChatPagingMergeTest.kt | 145 ++++++++++++++++++ .../ui/chat/ChatViewModelTest.kt | 80 ++++++++++ 6 files changed, 268 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt index 1c284fcf..566d7f0e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt @@ -69,6 +69,7 @@ class ChatClarifyDelegate( ChatMessage( role = MessageRole.USER, content = displayContent, + displayKind = "clarify_response", ) uiState.update { state -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt index 8623c694..53d45508 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt @@ -36,7 +36,7 @@ private const val MAX_ITERATIONS_SYSTEM_MARKER = */ internal fun ChatMessage.isUserTurnBoundary(): Boolean = role == MessageRole.USER && - (displayKind == null || displayKind == "steer") && + (displayKind == null || displayKind == "steer" || displayKind == "clarify_response") && !content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) /** 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 441728ce..54d18db6 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 @@ -171,6 +171,7 @@ internal class TranscriptComparison( } val ta = trimmed.getOrPut(a.content) { a.content.trim() } val tb = trimmed.getOrPut(b.content) { b.content.trim() } + if (ta.startsWith("/") || tb.startsWith("/")) return false if (ta == tb) return true if (a.role == MessageRole.USER && captions.getOrPut(ta) { stripAttachmentRefLines(ta) } == @@ -345,7 +346,7 @@ internal fun mergeCachedTranscriptPage( return ( current.map { replacements[it.id] ?: it } + incoming.filterIndexed { index, _ -> matches[index] == null } - ).dedupeById().inTranscriptOrder(current, resolvedOrders).reconcileReasoningRows() + ).dedupeById().inTranscriptOrder(incoming + current, resolvedOrders).reconcileReasoningRows() } /** Merge one page with the current snapshot without consuming repeated results more than once. */ @@ -412,7 +413,7 @@ internal fun mergeTranscriptWithLive( .reconcileReasoningRows() } -/** Keep server order; place local notices after their last preceding confirmed message, not at the transcript tail. */ +/** Keep server order; place local notices and commands after their last preceding confirmed message, not at the transcript tail. */ private fun List.inTranscriptOrder( previous: List, resolvedOrders: Map, @@ -421,39 +422,64 @@ private fun List.inTranscriptOrder( var precedingCanonical: Long? = null var hasPendingPredecessor = false var pendingLocalOrder: Long? = null - val noticeAnchors = mutableMapOf() - val pendingOrderByNotice = mutableMapOf() + val localAnchors = mutableMapOf() + val pendingOrderByLocal = 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] = + } else if (message.isPermanentlyLocal()) { + localAnchors[message.id] = if (hasPendingPredecessor) { Long.MAX_VALUE } else { precedingCanonical?.takeIf { it >= 0L } ?: latestCanonical } - if (hasPendingPredecessor) pendingOrderByNotice[message.id] = pendingLocalOrder ?: Long.MAX_VALUE + if (hasPendingPredecessor) pendingOrderByLocal[message.id] = pendingLocalOrder ?: Long.MAX_VALUE + } else { + hasPendingPredecessor = true + pendingLocalOrder = message.localOrder } } 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 } + it.value.canonicalOrder ?: localAnchors[it.value.id] ?: Long.MAX_VALUE + }.thenBy { if (localAnchors[it.value.id]?.let { anchor -> anchor != Long.MAX_VALUE } == true) 1 else 0 } + .thenBy { it.value.localOrder ?: pendingOrderByLocal[it.value.id] ?: Long.MAX_VALUE } .thenBy { previousIndices[it.value.id] ?: it.index }, ).map { it.value } } +internal fun ChatMessage.isPermanentlyLocal(): Boolean = + role == MessageRole.SYSTEM || + (role == MessageRole.USER && (content.startsWith("/") || displayKind == "clarify_response")) || + (role == MessageRole.ASSISTANT && canonicalRestId == null && isLocalFeedbackRow(this)) + +private fun isLocalFeedbackRow(message: ChatMessage): Boolean = + message.localOrder != null || isLocalFeedbackContent(message.content) + +private fun isLocalFeedbackContent(content: String): Boolean = + content.endsWith(" is not supported on mobile") || + content.startsWith("usage: /queue") || + content.startsWith("reasoning: ") || + content.startsWith("Could not read reasoning status:") || + content == "Reasoning is not supported for the current model." || + content == "Reasoning cannot be disabled for this model (always on)." || + content.startsWith("Could not change reasoning:") || + content == "Reasoning controls require an active session." || + content == "No active session. Use `/new` to create one." || + content == "No active session to undo." || + content == "No active session for side questions. Start a chat first." || + content.startsWith("Bot chats are one continuous conversation — compacting instead.") || + content.startsWith("⚠ ") || + (content.startsWith("/") && content.contains(": ")) || + content == "Failed to undo." + internal fun ChatMessage.isSessionStartMarker(): Boolean = role == MessageRole.SYSTEM && (content == "Session created" || content == "Session branched") diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt index f71f7aeb..25d1bac6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt @@ -57,7 +57,8 @@ private fun ChatMessage.isSyntheticSystemRow(): Boolean = displayKind == null && content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) -internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind != "steer" +internal fun ChatMessage.isTimelineMarker(): Boolean = + displayKind != null && displayKind != "steer" && displayKind != "clarify_response" /** * Split a flat message list into turns for the full-bleed renderer. 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 3f77240f..53ba904b 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 @@ -748,6 +748,151 @@ class ChatPagingMergeTest { assertEquals(listOf("rest-s-1", "live-reply", "review", "rest-s-3"), synced.map { it.id }) } + @Test + fun stopCommandAndSessionInterruptedStayInPlaceAcrossFutureSyncs() { + val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task") + val partialAssistant = + ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "Working on it...") + val stopCommand = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop") + val interrupted = + ChatMessage(id = "uuid-interrupted", role = MessageRole.SYSTEM, content = "Session interrupted") + + val current = listOf(prompt, partialAssistant, stopCommand, interrupted) + + // Now later turn arrives from server: user asks something else, assistant answers + val serverRows = + listOf( + SessionMessage( + id = 0, + role = "user", + content = JsonPrimitive("long running task"), + timestamp = JsonPrimitive(1), + ), + SessionMessage( + id = 1, + role = "assistant", + content = JsonPrimitive("Working on it..."), + timestamp = JsonPrimitive(2), + ), + SessionMessage( + id = 2, + role = "user", + content = JsonPrimitive("next question"), + timestamp = JsonPrimitive(3), + ), + SessionMessage( + id = 3, + role = "assistant", + content = JsonPrimitive("next answer"), + timestamp = JsonPrimitive(4), + ), + ) + + val merged = applyServerPage(current, serverRows) + + assertEquals( + listOf( + "rest-session-0", + "rest-session-1", + "uuid-stop", + "uuid-interrupted", + "rest-session-2", + "rest-session-3", + ), + merged.map { it.canonicalRestId ?: it.id }, + ) + } + + @Test + fun commandEchoAndOutputStayInPlaceAcrossFutureSyncs() { + val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "what model are you?") + val answer = ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "I am Hermes.") + val command = ChatMessage(id = "uuid-cmd", role = MessageRole.USER, content = "/reasoning") + val output = + ChatMessage(id = "uuid-output", role = MessageRole.ASSISTANT, content = "reasoning: low · display on") + + val current = listOf(prompt, answer, command, output) + + val serverRows = + listOf( + SessionMessage( + id = 0, + role = "user", + content = JsonPrimitive("what model are you?"), + timestamp = JsonPrimitive(1), + ), + SessionMessage( + id = 1, + role = "assistant", + content = JsonPrimitive("I am Hermes."), + timestamp = JsonPrimitive(2), + ), + SessionMessage( + id = 2, + role = "user", + content = JsonPrimitive("next question"), + timestamp = JsonPrimitive(3), + ), + SessionMessage( + id = 3, + role = "assistant", + content = JsonPrimitive("next answer"), + timestamp = JsonPrimitive(4), + ), + ) + + val merged = applyServerPage(current, serverRows) + + assertEquals( + listOf("rest-session-0", "rest-session-1", "uuid-cmd", "uuid-output", "rest-session-2", "rest-session-3"), + merged.map { it.canonicalRestId ?: it.id }, + ) + } + + @Test + fun clarifyAnswerStaysInPlaceAcrossFutureSyncs() { + val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "deploy this") + val assistant = ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "Where?") + val clarifyAnswer = + ChatMessage( + id = "uuid-clarify", + role = MessageRole.USER, + content = "production", + displayKind = "clarify_response", + ) + + val current = listOf(prompt, assistant, clarifyAnswer) + + val serverRows = + listOf( + SessionMessage( + id = 0, + role = "user", + content = JsonPrimitive("deploy this"), + timestamp = JsonPrimitive(1), + ), + SessionMessage( + id = 1, + role = "assistant", + content = JsonPrimitive("Where?"), + timestamp = JsonPrimitive(2), + ), + SessionMessage( + id = 2, + role = "assistant", + content = JsonPrimitive("Deploying to production now..."), + timestamp = JsonPrimitive(3), + ), + ) + + val merged = applyServerPage(current, serverRows) + + assertEquals( + listOf("rest-session-0", "rest-session-1", "uuid-clarify", "rest-session-2"), + merged.map { it.canonicalRestId ?: it.id }, + ) + } + @Test fun sessionCreatedMarkerStaysAtStartOfTranscript() { val sessionCreated = 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 e1faf600..3e77cf3e 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 @@ -700,6 +700,86 @@ class ChatViewModelTest { verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } } + @Test + fun testSlashCommand_stop_andInterruptedNoticeStayInPlaceAcrossSync() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + // 1. Initial messages in UI state + val m0 = ChatMessage(id = "rest-$sessionId-0", role = MessageRole.USER, content = "start task") + val m1 = ChatMessage(id = "rest-$sessionId-1", role = MessageRole.ASSISTANT, content = "running...") + val stateField = + ChatViewModel::class.java + .getDeclaredField("_uiState") + .apply { isAccessible = true } + + @Suppress("UNCHECKED_CAST") + val state = stateField.get(viewModel) as MutableStateFlow + state.value = viewModel.uiState.value.copy(messages = listOf(m0, m1)) + + every { + HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) + } answers { + val onSent = thirdArg<((String) -> Unit)?>() + onSent?.invoke("interrupt-req-1") + "interrupt-req-1" + } + + // 2. User sends /stop + viewModel.sendMessage("/stop") + advanceUntilIdle() + + // 3. WS returns interrupt success -> adds "Session interrupted" + mockEventsFlow.emit(WsEvent.RpcResult("interrupt-req-1", mapOf("ok" to true))) + advanceUntilIdle() + + // 4. Server transcript sync arrives with newer turns + val serverRows = + listOf( + com.m57.hermescontrol.data.model.SessionMessage( + id = 0, + role = "user", + content = kotlinx.serialization.json.JsonPrimitive("start task"), + timestamp = kotlinx.serialization.json.JsonPrimitive(1), + ), + com.m57.hermescontrol.data.model.SessionMessage( + id = 1, + role = "assistant", + content = kotlinx.serialization.json.JsonPrimitive("running..."), + timestamp = kotlinx.serialization.json.JsonPrimitive(2), + ), + com.m57.hermescontrol.data.model.SessionMessage( + id = 2, + role = "user", + content = kotlinx.serialization.json.JsonPrimitive("next prompt"), + timestamp = kotlinx.serialization.json.JsonPrimitive(3), + ), + com.m57.hermescontrol.data.model.SessionMessage( + id = 3, + role = "assistant", + content = kotlinx.serialization.json.JsonPrimitive("next reply"), + timestamp = kotlinx.serialization.json.JsonPrimitive(4), + ), + ) + + // Simulate sync/merge with serverRows + val current = viewModel.uiState.value.messages + val merged = + com.m57.hermescontrol.ui.chat.mergeTranscriptWithLive( + com.m57.hermescontrol.ui.chat + .mapServerMessages(sessionId, serverRows, 0, true, current), + current, + chronological = true, + preserveLiveIds = true, + ) + + val contents = merged.map { it.content } + assertEquals( + listOf("start task", "running...", "/stop", "Session interrupted", "next prompt", "next reply"), + contents, + ) + } + @Test fun testSlashCommand_interrupt_sendsInterrupt() = runTest { From 9ae7b6a52069284f91dfbfa40639f31286ee0cf1 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:38:31 +0300 Subject: [PATCH 2/2] refactor(chat): tag local feedback with displayKind and refine logical message matching (#1241) --- .../ui/chat/ChatTranscriptMerge.kt | 36 +++++-------- .../hermescontrol/ui/chat/ChatViewModel.kt | 7 ++- .../ui/chat/fullbleed/FullBleedTurns.kt | 5 +- .../ui/chat/ChatPagingMergeTest.kt | 52 ++++++++++++++++++- .../ui/chat/FullBleedTurnsTest.kt | 10 ++++ 5 files changed, 85 insertions(+), 25 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 54d18db6..cede6a9c 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 @@ -169,9 +169,21 @@ internal class TranscriptComparison( val key = toolKey(a.content) return key != null && key == toolKey(b.content) } + // Rows generated only by Mobile never have a canonical REST counterpart. + // Do not let coincidentally identical server text claim their identity. + if (a.role == MessageRole.ASSISTANT && + (a.displayKind == "local_feedback" || b.displayKind == "local_feedback") + ) { + return false + } + if (a.role == MessageRole.USER && + (a.displayKind == "clarify_response" || b.displayKind == "clarify_response") + ) { + return false + } val ta = trimmed.getOrPut(a.content) { a.content.trim() } val tb = trimmed.getOrPut(b.content) { b.content.trim() } - if (ta.startsWith("/") || tb.startsWith("/")) return false + if (a.role == MessageRole.USER && (ta.startsWith("/") || tb.startsWith("/"))) return false if (ta == tb) return true if (a.role == MessageRole.USER && captions.getOrPut(ta) { stripAttachmentRefLines(ta) } == @@ -458,27 +470,7 @@ private fun List.inTranscriptOrder( internal fun ChatMessage.isPermanentlyLocal(): Boolean = role == MessageRole.SYSTEM || (role == MessageRole.USER && (content.startsWith("/") || displayKind == "clarify_response")) || - (role == MessageRole.ASSISTANT && canonicalRestId == null && isLocalFeedbackRow(this)) - -private fun isLocalFeedbackRow(message: ChatMessage): Boolean = - message.localOrder != null || isLocalFeedbackContent(message.content) - -private fun isLocalFeedbackContent(content: String): Boolean = - content.endsWith(" is not supported on mobile") || - content.startsWith("usage: /queue") || - content.startsWith("reasoning: ") || - content.startsWith("Could not read reasoning status:") || - content == "Reasoning is not supported for the current model." || - content == "Reasoning cannot be disabled for this model (always on)." || - content.startsWith("Could not change reasoning:") || - content == "Reasoning controls require an active session." || - content == "No active session. Use `/new` to create one." || - content == "No active session to undo." || - content == "No active session for side questions. Start a chat first." || - content.startsWith("Bot chats are one continuous conversation — compacting instead.") || - content.startsWith("⚠ ") || - (content.startsWith("/") && content.contains(": ")) || - content == "Failed to undo." + (role == MessageRole.ASSISTANT && displayKind == "local_feedback") internal fun ChatMessage.isSessionStartMarker(): Boolean = role == MessageRole.SYSTEM && (content == "Session created" || content == "Session branched") 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 fef5ec71..8c8ad8a5 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 @@ -2655,7 +2655,12 @@ class ChatViewModel( } private fun addAssistantMessage(text: String) { - val msg = ChatMessage(role = MessageRole.ASSISTANT, content = text) + val msg = + ChatMessage( + role = MessageRole.ASSISTANT, + content = text, + displayKind = "local_feedback", + ) _uiState.update { it.copy(messages = it.messages + msg) } // Persist — OUTSIDE update{} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt index 25d1bac6..fafc2ac3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt @@ -58,7 +58,10 @@ private fun ChatMessage.isSyntheticSystemRow(): Boolean = content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) internal fun ChatMessage.isTimelineMarker(): Boolean = - displayKind != null && displayKind != "steer" && displayKind != "clarify_response" + displayKind != null && + displayKind != "steer" && + displayKind != "clarify_response" && + displayKind != "local_feedback" /** * Split a flat message list into turns for the full-bleed renderer. 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 53ba904b..def65f96 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 @@ -581,6 +581,51 @@ class ChatPagingMergeTest { assertTrue(sameLogicalMessage(canonical.copy(reasoningText = "new trace"), live)) } + @Test + fun assistantReplyBeginningWithSlashStillMatchesCanonicalEcho() { + val live = + ChatMessage( + id = "uuid-answer", + role = MessageRole.ASSISTANT, + content = "/help is the command you're looking for.", + ) + val canonical = + live.copy( + id = "rest-session-10", + restId = null, + ) + + assertTrue(sameLogicalMessage(canonical, live)) + } + + @Test + fun permanentlyLocalRowsDoNotMatchCanonicalRowsByContent() { + val localFeedback = + ChatMessage( + id = "uuid-feedback", + role = MessageRole.ASSISTANT, + content = "Command completed successfully", + displayKind = "local_feedback", + ) + val canonicalAssistant = + ChatMessage( + id = "rest-session-10", + role = MessageRole.ASSISTANT, + content = "Command completed successfully", + ) + val clarify = + ChatMessage( + id = "uuid-clarify", + role = MessageRole.USER, + content = "yes", + displayKind = "clarify_response", + ) + val canonicalUser = ChatMessage(id = "rest-session-11", role = MessageRole.USER, content = "yes") + + assertTrue(!sameLogicalMessage(canonicalAssistant, localFeedback)) + assertTrue(!sameLogicalMessage(canonicalUser, clarify)) + } + @Test fun stripGatewaySteerWrapperRemovesEnvelopesSafely() { val standard = @@ -809,7 +854,12 @@ class ChatPagingMergeTest { val answer = ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "I am Hermes.") val command = ChatMessage(id = "uuid-cmd", role = MessageRole.USER, content = "/reasoning") val output = - ChatMessage(id = "uuid-output", role = MessageRole.ASSISTANT, content = "reasoning: low · display on") + ChatMessage( + id = "uuid-output", + role = MessageRole.ASSISTANT, + content = "Command completed successfully", + displayKind = "local_feedback", + ) val current = listOf(prompt, answer, command, output) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt index b902486c..aff84307 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt @@ -103,6 +103,16 @@ class FullBleedTurnsTest { assertEquals(listOf(ChatTurn.User(steer)), result) } + @Test + fun `local feedback display kind remains assistant prose`() { + val feedback = + msg("a1", MessageRole.ASSISTANT, content = "Command completed successfully") + .copy(displayKind = "local_feedback") + val result = groupIntoTurns(listOf(feedback)) + + assertEquals(listOf(entries(AgentEntry.Prose(feedback))), result) + } + @Test fun `max-iterations nudge is a SystemEvent not a user bubble`() { // Backend persists the max-iterations notice as a plain role=user row