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:
2 files changed
+77
No files matched your search
@@ -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:<path>`,
|
||||
* `@file:<ref>`, 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user