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 b536fdae..b4f63282 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 @@ -196,7 +196,14 @@ internal fun mapServerMessages( return@forEachIndexed } - var finalContent = rawContent + // Context-reference preprocessing can persist model-facing attachment + // contents after the user's text. A cold reload / second device has no + // optimistic local bubble to hide that enriched REST representation, + // so project the producer-owned suffix out at hydration time too. + // 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 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 a485a53e..43cbdcab 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 @@ -6,6 +6,38 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +private val ATTACHED_CONTEXT_MARKER_RE = + Regex("""(?:^|\n)--- Attached Context ---\s*\n""") + +private val CONTEXT_REF_RE = + Regex( + """@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)""", + ) + +/** + * Remove a gateway-generated `--- Attached Context ---` suffix while + * preserving the user-authored portion of the message. + * + * The marker itself is plain text a user can legitimately type, so do not + * treat it as a producer boundary unless the suffix also contains a context + * reference of the shape emitted by the gateway. + * + * Unlike [stripAttachmentRefLines], this intentionally keeps `@file:` / + * `@image:` references in the visible text. REST-only hydration has no local + * optimistic bubble proving those references were injected by Mobile, and + * users may have authored context references themselves. + */ +internal fun stripGatewayAttachedContext(content: String): String { + val marker = ATTACHED_CONTEXT_MARKER_RE.find(content) ?: return content + val attachedContext = content.substring(marker.range.last + 1) + + if (!CONTEXT_REF_RE.containsMatchIn(attachedContext)) { + return content + } + + return content.substring(0, marker.range.first).trimEnd() +} + /** * Canonical comparison key for a tool message's result payload. * @@ -127,7 +159,7 @@ internal fun sameLogicalMessage( * run_agent). Blank lines left behind are dropped too. */ internal fun stripAttachmentRefLines(content: String): String = - content + stripGatewayAttachedContext(content) .lines() .map { it.trim() } .filterNot { line -> 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 1154a550..ccde9151 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 @@ -1877,7 +1877,7 @@ class ChatViewModel( "filename" to attachment.name, "ext" to attachment.fileExtension, ), - ) + ).let { if (it is JsonElement) it.toAny() else it } if (result != null) { @Suppress("UNCHECKED_CAST") val ok = (result as? Map)?.get("attached") as? Boolean @@ -1897,7 +1897,10 @@ class ChatViewModel( "data_url" to "data:${attachment.mimeType};base64,$b64", "name" to attachment.name, ), - )?.let { result -> + )?.let { response -> + // request() returns raw JsonElement values; normalize before reading Kotlin types. + val result = if (response is JsonElement) response.toAny() else response + @Suppress("UNCHECKED_CAST") val refText = (result as? Map)?.get("ref_text") as? String 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 68a69b9c..84918f52 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 @@ -120,6 +120,100 @@ class ChatViewModelTest { assertEquals("ws-local-1", merged.single().id) } + @Test + fun mergeTranscriptWithLive_collapsesGatewayAttachmentContextCopy() { + val local = + ChatMessage( + id = "ws-local-attachment", + role = MessageRole.USER, + content = "What is the secret word in the attached file?", + timestamp = 100L, + ) + val rest = + ChatMessage( + id = "rest-sess-attachment", + role = MessageRole.USER, + content = + """ + @file:files/agent-vault/hermes/attachments/note.txt + + What is the secret word in the attached file? + + --- Attached Context --- + + 📄 @file:files/agent-vault/hermes/attachments/note.txt (8 tokens) + ``` + THE_SECRET_WORD_IS_MANGO_8421 + ``` + """.trimIndent(), + timestamp = 100L, + ) + + val merged = mergeTranscriptWithLive(listOf(rest), listOf(local)) + + assertEquals(1, merged.size) + assertEquals("ws-local-attachment", merged.single().id) + } + + @Test + fun mapServerMessages_restOnlyAttachmentHidesGatewayContext() { + val ref = "@file:files/agent-vault/hermes/attachments/note.txt" + val persisted = + """ + $ref + + What is the secret word in the attached file? + + --- Attached Context --- + + 📄 $ref (8 tokens) + ``` + THE_SECRET_WORD_IS_MANGO_8421 + ``` + """.trimIndent() + + val mapped = + mapServerMessages( + sessionId = "session-1", + messages = + listOf( + SessionMessage( + id = 42, + role = "user", + content = JsonPrimitive(persisted), + timestamp = JsonPrimitive("100"), + ), + ), + offset = 0, + latestPaging = true, + liveMessages = emptyList(), + ) + + assertEquals(1, mapped.size) + assertEquals( + """ + $ref + + What is the secret word in the attached file? + """.trimIndent(), + mapped.single().content, + ) + assertFalse(mapped.single().content.contains("--- Attached Context ---")) + assertFalse(mapped.single().content.contains("THE_SECRET_WORD_IS_MANGO_8421")) + } + + @Test + fun stripAttachmentRefLines_preservesUserAuthoredAttachedContextHeading() { + val authored = + """ + Explain this heading: + --- Attached Context --- + this is ordinary user-authored text + """.trimIndent() + + assertEquals(authored, stripAttachmentRefLines(authored)) + } + @Before fun setUp() { Dispatchers.setMain(testDispatcher) @@ -1981,6 +2075,34 @@ class ChatViewModelTest { verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } } + @Test + fun sendMessage_rawJsonFileAttachmentRetainsReference() = + runTest { + val (viewModel, _) = createViewModelWithSession() + val uriString = "content://test/note" + val uri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse(uriString) } returns uri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(uri) } answers { "hello".byteInputStream() } + every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns + CompletableDeferred( + buildJsonObject { + put("attached", true) + put("ref_text", "@file:attachments/note.txt") + }, + ) + viewModel.addAttachment(uriString, "note.txt", "text/plain", 5) + + viewModel.sendMessage("Inspect file") + advanceUntilIdle() + + verify { + HermesWsClient.sendMessage(any(), "@file:attachments/note.txt\n\nInspect file", any(), any()) + } + } + @Test fun sendMessage_laterGrowingAttachmentDoesNotPartiallyUploadEarlierAttachments() = runTest {