fix(chat): dedupe user bubbles enriched with attachment ref lines (#952)

Sending an image/file renders the user message twice: the optimistic
bubble carries the raw caption while the backend persists an enriched
copy with "@image:" / "@file:" ref lines and a "[screenshot]" marker
(_build_image_ref_message / run_agent multipart flattening). The trimmed
exact-content compare in sameLogicalMessage fails on that drift and the
prefix fallback gate (>=40 chars) rejects short captions, so the REST
transcript sync appends the REST copy as a second bubble.

Compare USER rows on the caption with attachment-injection lines
stripped (stripAttachmentRefLines). USER-only so assistant text that
legitimately mentions these tokens is never collapsed.

Device-verified on the hermes37 emulator: image send + transcript
resync renders a single user bubble, restart/reload stays clean.
Unit: ChatToolDedupeTest 32/32.
This commit is contained in:
M57 authored and GitHub committed 2026-08-22 12:33:48 +03:00
1 parent e9f2c726e4
commit 5153e1572f
2 files changed
+77

No files matched your search

@@ -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