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