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

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