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 ta = a.content.trim()
|
||||||
val tb = b.content.trim()
|
val tb = b.content.trim()
|
||||||
if (ta == tb) return true
|
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
|
// 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
|
// short of the streamed narration (the last delta was still in the
|
||||||
// throttled buffer when tool.start sealed the message). The backend
|
// throttled buffer when tool.start sealed the message). The backend
|
||||||
@@ -153,6 +167,25 @@ internal fun sameLogicalMessage(
|
|||||||
(tb.startsWith(ta) || ta.startsWith(tb))
|
(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,
|
* Room accumulates BOTH the WS-persisted copy (UUID id, rich tool payload,
|
||||||
* tool name) and the REST-persisted copy (`rest-` id, result-only payload,
|
* tool name) and the REST-persisted copy (`rest-` id, result-only payload,
|
||||||
|
|||||||
@@ -133,6 +133,50 @@ class ChatToolDedupeTest {
|
|||||||
assertFalse(sameLogicalMessage(ws, rest))
|
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 ───────────────────────────────────────────────
|
// ── dedupeCachedMessages ───────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in new issue
Block a user