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 f9645cb4..851ec72a 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 @@ -141,6 +141,20 @@ internal fun sameLogicalMessage( val ta = a.content.trim() val tb = b.content.trim() if (ta == tb) return true + // Attachment dedupe: when the user sends an image/file, the backend + // persists an ENRICHED copy of the prompt — the real caption plus + // `@image:`/`@file:` reference lines and a `[screenshot]` marker + // (tui_gateway _build_image_ref_message / run_agent flattening). The + // optimistic bubble carries the raw caption only, so the exact compare + // above fails and the REST sync renders the same message twice. Compare + // USER rows on the caption with injection lines stripped (both sides — + // stripping a clean string is a no-op). USER-only so assistant text that + // legitimately mentions these tokens is never collapsed. + if (a.role == MessageRole.USER) { + val ca = stripAttachmentRefLines(ta) + val cb = stripAttachmentRefLines(tb) + if (ca == cb) return true + } // Issue #842: a seal race can leave the live orphan a few trailing tokens // short of the streamed narration (the last delta was still in the // throttled buffer when tool.start sealed the message). The backend @@ -153,6 +167,25 @@ internal fun sameLogicalMessage( (tb.startsWith(ta) || ta.startsWith(tb)) } +/** + * Remove backend attachment-injection lines from a user message so the + * optimistic bubble and the server-enriched REST copy compare equal. + * Lines the gateway adds on top of the user's caption: `@image:`, + * `@file:`, and `[screenshot]` (multipart image placeholder written by + * run_agent). Blank lines left behind are dropped too. + */ +internal fun stripAttachmentRefLines(content: String): String = + content + .lines() + .map { it.trim() } + .filterNot { line -> + line.startsWith("@image:") || + line.startsWith("@file:") || + line == "[screenshot]" + } + .joinToString("\n") + .trim() + /** * Room accumulates BOTH the WS-persisted copy (UUID id, rich tool payload, * tool name) and the REST-persisted copy (`rest-` id, result-only payload, diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatToolDedupeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatToolDedupeTest.kt index 5a4c1413..e2150aef 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatToolDedupeTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatToolDedupeTest.kt @@ -133,6 +133,50 @@ class ChatToolDedupeTest { assertFalse(sameLogicalMessage(ws, rest)) } + @Test + fun sameLogicalMessage_userAttachmentEnrichment_stillSame() { + // User attaches an image: the optimistic bubble carries the RAW caption, + // the backend persists caption + `@image:` ref + `[screenshot]` marker. + // They are the same logical message — the sync must not duplicate it. + val optimistic = ChatMessage(role = MessageRole.USER, content = "what is this image") + val rest = + ChatMessage( + role = MessageRole.USER, + content = + "what is this image\n" + + "@image:/.hermes/images/upload_20260822_111444_1.jpg\n" + + "[screenshot]", + ) + assertTrue(sameLogicalMessage(optimistic, rest)) + assertTrue(sameLogicalMessage(rest, optimistic)) + } + + @Test + fun sameLogicalMessage_userFileRefEnrichment_stillSame() { + // Same drift for file attachments: REST gains a `@file:` ref line. + val optimistic = ChatMessage(role = MessageRole.USER, content = "summarize this doc") + val rest = ChatMessage(role = MessageRole.USER, content = "summarize this doc\n@file:report.pdf") + assertTrue(sameLogicalMessage(optimistic, rest)) + } + + @Test + fun sameLogicalMessage_userDifferentCaptionsWithAttachments_notSame() { + // Stripping must not over-collapse: two DIFFERENT user captions that + // both carried images stay distinct. + val a = ChatMessage(role = MessageRole.USER, content = "look at this\n@image:/a.jpg") + val b = ChatMessage(role = MessageRole.USER, content = "now this one\n@image:/b.jpg") + assertFalse(sameLogicalMessage(a, b)) + } + + @Test + fun sameLogicalMessage_assistantMentioningRefLines_notStripped() { + // The strip is USER-only: an assistant reply that literally contains + // an @image: line must never merge with a different assistant message. + val a = ChatMessage(role = MessageRole.ASSISTANT, content = "here you go\n@image:/x.jpg") + val b = ChatMessage(role = MessageRole.ASSISTANT, content = "here you go") + assertFalse(sameLogicalMessage(a, b)) + } + // ── dedupeCachedMessages ─────────────────────────────────────────────── @Test