diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt index df2fb46f..e4f6cc96 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt @@ -53,7 +53,12 @@ interface ChatMessageDao { suspend fun upsert(message: ChatMessageEntity) { val existing = getMessage(message.id) val restId = message.restId ?: existing?.restId - val canonicalOrder = canonicalMessageOrder(restId ?: message.id, message.sessionId) + val canonicalOrder = + if (message.isSessionStartMarker()) { + -1L + } else { + canonicalMessageOrder(restId ?: message.id, message.sessionId) + } writeMessage( message.copy( restId = restId, diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt index b77843d4..0695140d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt @@ -56,6 +56,9 @@ data class ChatMessageEntity( val sortOrder: Long = 0, ) +internal fun ChatMessageEntity.isSessionStartMarker(): Boolean = + role == "SYSTEM" && (content == "Session created" || content == "Session branched") + /** Only the exact session prefix and a nonnegative decimal suffix identify a canonical row. */ internal fun canonicalMessageOrder( id: String, diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt index 3a246c3a..97c39b5b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt @@ -39,7 +39,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage = tps = tps, completionId = completionId, restId = restId, - localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 }, + localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 && !isSessionStartMarker() }, ) fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity = 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 ce074201..8623c694 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 == null || displayKind == "steer") && !content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt index 5a7ce54d..59327ce1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt @@ -164,7 +164,11 @@ internal fun mapServerMessages( // Keep the actual @file:/@image: token here: unlike the dedupe path, // REST-only hydration cannot prove that reference was not user-authored. var finalContent = - if (role == MessageRole.USER) stripGatewayAttachedContext(rawContent) else rawContent + if (role == MessageRole.USER) { + stripGatewayAttachedContext(stripGatewaySteerWrapper(rawContent)) + } else { + rawContent + } var attachments: List? = null if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) { val items = HostMediaExtractor.extract(rawContent) 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 6d7e6125..91ab94be 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 @@ -14,6 +14,30 @@ private val CONTEXT_REF_RE = """@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)""", ) +private val OUT_OF_BAND_START_RE = + Regex("""\[OUT-OF-BAND USER MESSAGE[^\n\]]*\]\s*""", RegexOption.IGNORE_CASE) +private val OUT_OF_BAND_END_RE = + Regex("""\s*\[/OUT-OF-BAND USER MESSAGE\]""", RegexOption.IGNORE_CASE) + +/** + * Remove a gateway-generated `[OUT-OF-BAND USER MESSAGE ...]` wrapper from + * mid-turn steering messages while preserving the user-authored prompt. + */ +internal fun stripGatewaySteerWrapper(content: String): String { + val trimmed = content.trimStart() + val startMatch = OUT_OF_BAND_START_RE.find(trimmed) ?: return content + if (startMatch.range.first != 0) return content + val afterStart = trimmed.substring(startMatch.range.last + 1) + val endMatch = OUT_OF_BAND_END_RE.find(afterStart) + val unwrapped = + if (endMatch != null) { + afterStart.substring(0, endMatch.range.first) + } else { + afterStart + } + return unwrapped.trim() +} + /** * Remove a gateway-generated `--- Attached Context ---` suffix while * preserving the user-authored portion of the message. @@ -231,7 +255,7 @@ internal fun matchTranscriptMessages( * run_agent). Blank lines left behind are dropped too. */ internal fun stripAttachmentRefLines(content: String): String = - stripGatewayAttachedContext(content) + stripGatewayAttachedContext(stripGatewaySteerWrapper(content)) .lines() .map { it.trim() } .filterNot { line -> @@ -276,7 +300,11 @@ internal fun dedupeCachedMessages( }.toMap() return unique.filterNot { it.id in echoes }.map { message -> aliases[message.id]?.let { - message.copy(restId = it.canonicalRestId, completionId = message.completionId ?: it.completionId) + message.copy( + restId = it.canonicalRestId, + completionId = message.completionId ?: it.completionId, + displayKind = message.displayKind ?: it.displayKind, + ) } ?: message } } @@ -306,6 +334,7 @@ internal fun mergeCachedTranscriptPage( id = match.id, restId = message.canonicalRestId ?: match.canonicalRestId, completionId = match.completionId ?: message.completionId, + displayKind = message.displayKind ?: match.displayKind, ) }.toMap() return ( @@ -333,6 +362,7 @@ internal fun mergeTranscriptWithLive( match?.role == MessageRole.USER -> { match.copy( restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id }, + displayKind = message.displayKind ?: match.displayKind, ) } @@ -378,8 +408,16 @@ private fun List.inTranscriptOrder(): List = .thenBy { it.canonicalOrder ?: it.localOrder ?: Long.MAX_VALUE }, ) +internal fun ChatMessage.isSessionStartMarker(): Boolean = + role == MessageRole.SYSTEM && (content == "Session created" || content == "Session branched") + private val ChatMessage.canonicalOrder: Long? - get() = if (localOrder != null && restId == null) null else canonicalRestId?.substringAfterLast('-')?.toLongOrNull() + get() = + when { + isSessionStartMarker() -> -1L + localOrder != null && restId == null -> null + else -> canonicalRestId?.substringAfterLast('-')?.toLongOrNull() + } /** Retain the canonical trace once, without copying it across a user/system/tool boundary. */ private fun List.reconcileReasoningRows(): List = 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 cc83a539..f71f7aeb 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,6 +57,8 @@ private fun ChatMessage.isSyntheticSystemRow(): Boolean = displayKind == null && content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) +internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind != "steer" + /** * Split a flat message list into turns for the full-bleed renderer. * @@ -80,11 +82,12 @@ fun groupIntoTurns(messages: List): List { // Timeline markers (display_kind) ride as role=user rows but are // NOT user turns — group them as system-style timeline entries so // they render as centered chips, not fake user bubbles (issue #904). + // Steer messages (display_kind == "steer") are genuine user turns. // The backend's max-iterations nudge is a role=user row with NO // display_kind (it's stripped on persistence); detect it by its // stable content prefix and route it as a system event too, tagging // it so the timeline chip can name it. - message.displayKind != null -> { + message.isTimelineMarker() -> { agentEntries += AgentEntry.SystemEvent(message) } diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt index bf254f79..5ce02d57 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt @@ -201,4 +201,24 @@ class ChatMessageMapperTest { assertNull(roundTripped.displayKind) } + + @Test + fun sessionStartMarkerDoesNotAcquireLocalOrder() { + val entity = + ChatMessageEntity( + id = "uuid-session-created", + sessionId = "s", + role = "SYSTEM", + content = "Session created", + timestamp = 1000L, + sortGroup = 1, + sortOrder = 5L, + ) + + val ui = entity.toUiModel() + + assertNull(ui.localOrder) + assertEquals(MessageRole.SYSTEM, ui.role) + assertEquals("Session created", ui.content) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatListEntriesTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatListEntriesTest.kt index 2f147964..9ed1e771 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatListEntriesTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatListEntriesTest.kt @@ -91,6 +91,28 @@ class ChatListEntriesTest { assertEquals(mapOf(6 to 5), toolCallMilestoneIndices(messages)) } + @Test + fun steerMessageResetsToolCounterAsUserTurnBoundary() { + val steer = + ChatMessage( + role = MessageRole.USER, + content = "Stop and run tests", + displayKind = "steer", + ) + val messages = + listOf( + userMessage(), + toolMessage(), + toolMessage(), + steer, + toolMessage(), + toolMessage(), + toolMessage(), + ) + // Only 3 tool calls after the steer boundary, so no 5-call milestone reached + assertTrue(toolCallMilestones(messages).isEmpty()) + } + @Test fun syntheticMaxIterationsNudgeDoesNotResetToolCounter() { val nudge = 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 dcf91260..022b1360 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,133 @@ class ChatPagingMergeTest { assertTrue(sameLogicalMessage(canonical.copy(reasoningText = "new trace"), live)) } + @Test + fun stripGatewaySteerWrapperRemovesEnvelopesSafely() { + val standard = + "[OUT-OF-BAND USER MESSAGE — a direct message from the user, delivered once at this position; " + + "not tool output and not a new delivery when replayed from conversation history]\n" + + "Please use Python instead.\n" + + "[/OUT-OF-BAND USER MESSAGE]" + assertEquals("Please use Python instead.", stripGatewaySteerWrapper(standard)) + + val noDescription = + "[OUT-OF-BAND USER MESSAGE]\n" + + "Halt.\n" + + "[/OUT-OF-BAND USER MESSAGE]" + assertEquals("Halt.", stripGatewaySteerWrapper(noDescription)) + + val multiline = + "[OUT-OF-BAND USER MESSAGE — direct steer]\n" + + "First line\n" + + "Second line\n" + + "[/OUT-OF-BAND USER MESSAGE]" + assertEquals("First line\nSecond line", stripGatewaySteerWrapper(multiline)) + + val plain = "Normal user message without wrapper" + assertEquals(plain, stripGatewaySteerWrapper(plain)) + + val interior = "Mentions [OUT-OF-BAND USER MESSAGE] in passing" + assertEquals(interior, stripGatewaySteerWrapper(interior)) + } + + @Test + fun midTurnSteerMessageReconcilesAndSortsChronologically() { + val userPrompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "Create a script") + val liveSteer = + ChatMessage( + id = "uuid-steer", + role = MessageRole.USER, + content = "Use Python instead", + ) + val inFlightAssistant = + ChatMessage( + id = "uuid-assistant", + role = MessageRole.ASSISTANT, + content = "Here is the Python script", + isStreaming = false, + ) + val current = listOf(userPrompt, inFlightAssistant, liveSteer) + + val serverRows = + listOf( + SessionMessage( + id = 0, + role = "user", + content = JsonPrimitive("Create a script"), + timestamp = JsonPrimitive(0), + ), + SessionMessage( + id = 1, + role = "user", + content = + JsonPrimitive( + "[OUT-OF-BAND USER MESSAGE — a direct message from the user, " + + "delivered once at this position; not tool output and not a new " + + "delivery when replayed from conversation history]\n" + + "Use Python instead\n" + + "[/OUT-OF-BAND USER MESSAGE]", + ), + display_kind = "steer", + timestamp = JsonPrimitive(1), + ), + SessionMessage( + id = 2, + role = "assistant", + content = JsonPrimitive("Here is the Python script"), + timestamp = JsonPrimitive(2), + ), + ) + + val merged = applyServerPage(current, serverRows) + + assertEquals(3, merged.size) + assertEquals("rest-session-0", merged[0].id) + assertEquals("Create a script", merged[0].content) + + assertEquals("uuid-steer", merged[1].id) + assertEquals("rest-session-1", merged[1].canonicalRestId) + assertEquals("Use Python instead", merged[1].content) + assertEquals("steer", merged[1].displayKind) + + assertEquals("rest-session-2", merged[2].canonicalRestId) + assertEquals("Here is the Python script", merged[2].content) + } + + @Test + fun sessionCreatedMarkerStaysAtStartOfTranscript() { + val sessionCreated = + ChatMessage( + id = "uuid-sys-start", + role = MessageRole.SYSTEM, + content = "Session created", + ) + val current = listOf(sessionCreated) + + val serverRows = + listOf( + SessionMessage( + id = 0, + role = "user", + content = JsonPrimitive("Hello"), + timestamp = JsonPrimitive(1), + ), + SessionMessage( + id = 1, + role = "assistant", + content = JsonPrimitive("Hi there!"), + timestamp = JsonPrimitive(2), + ), + ) + + val merged = applyServerPage(current, serverRows) + + assertEquals(3, merged.size) + assertEquals("uuid-sys-start", merged[0].id) + assertEquals("Session created", merged[0].content) + assertEquals("rest-session-0", merged[1].canonicalRestId) + assertEquals("rest-session-1", merged[2].canonicalRestId) + } + private fun assertBoundaryTranscript(messages: List) { assertEquals(listOf("rest-session-100", "rest-session-200"), messages.map { it.canonicalRestId }) assertEquals(listOf("boundary trace", ""), messages.map { it.reasoningText }) 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 6e83b523..b902486c 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 @@ -94,6 +94,15 @@ class FullBleedTurnsTest { assertEquals(listOf(entries(AgentEntry.SystemEvent(marker))), result) } + @Test + fun `steer message with display_kind steer is a User turn not a SystemEvent entry`() { + val steer = + msg("u1", MessageRole.USER, content = "Stop and run tests") + .copy(displayKind = "steer") + val result = groupIntoTurns(listOf(steer)) + assertEquals(listOf(ChatTurn.User(steer)), 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 diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt index bc49b346..c886e921 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat.fakes import com.m57.hermescontrol.data.local.ChatMessageDao import com.m57.hermescontrol.data.local.ChatMessageEntity import com.m57.hermescontrol.data.local.canonicalMessageOrder +import com.m57.hermescontrol.data.local.isSessionStartMarker import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap @@ -86,7 +87,12 @@ class FakeChatMessageDao : ChatMessageDao { fun addMessageDirect(message: ChatMessageEntity) { val existing = messages[message.id] if (existing == null) insertionSequence++ - val order = canonicalMessageOrder(message.restId ?: message.id, message.sessionId) + val order = + if (message.isSessionStartMarker()) { + -1L + } else { + canonicalMessageOrder(message.restId ?: message.id, message.sessionId) + } messages[message.id] = message.copy( sortGroup = if (order != null) 0 else 1,