fix(chat): stop prefix dedupe merging distinct messages (#1335) (#1340)

This commit is contained in:
m57 authored and GitHub committed 2026-09-28 00:32:07 +03:00
1 parent d81fc39f25
commit 2bf51cb911
2 files changed
+165

No files matched your search

@@ -201,6 +201,9 @@ internal class TranscriptComparison(
) {
return true
}
// #842 / #1335: prefix drift only exists for sealed assistant narration; two REST
// rows already returned above, so user rows must never collapse by prefix.
if (a.role != MessageRole.ASSISTANT) return false
return ta.length >= 40 && tb.length >= 40 && (tb.startsWith(ta) || ta.startsWith(tb))
}
}
@@ -0,0 +1,162 @@
package com.m57.hermescontrol.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Issue #1335: prefix dedupe must not merge distinct messages.
*/
class ChatPrefixDedupeTest {
// ── Contract 1 ────────────────────────────────────────────────────────────
// Two USER messages, both >=40 chars, one a strict prefix of the other,
// different ids -> NOT the same logical message (both survive a merge).
@Test
fun contract1_userMessages_strictPrefixDistinctIds_notSameLogicalMessage() {
val prefixText = "Please write a comprehensive test suite for issue thirteen thirty five."
val fullText = "$prefixText It should cover all edge cases thoroughly."
val msg1 = ChatMessage(id = "user-msg-1", role = MessageRole.USER, content = prefixText)
val msg2 = ChatMessage(id = "user-msg-2", role = MessageRole.USER, content = fullText)
assertFalse(sameLogicalMessage(msg1, msg2))
assertFalse(sameLogicalMessage(msg2, msg1))
val merged = mergeTranscriptWithLive(listOf(msg2), listOf(msg1), preserveLiveIds = true)
assertEquals(2, merged.size)
assertTrue(merged.any { it.id == "user-msg-1" && it.content == prefixText })
assertTrue(merged.any { it.id == "user-msg-2" && it.content == fullText })
}
// ── Contract 2 ────────────────────────────────────────────────────────────
// Two ASSISTANT messages that are both canonical REST rows (distinct restIds /
// canonical ids), one a >=40-char prefix of the other -> NOT same.
@Test
fun contract2_assistantCanonicalRestRows_strictPrefix_notSameLogicalMessage() {
val prefixText = "I have inspected the project repository and verified the build files."
val fullText = "$prefixText Now proceeding with the implementation of the requested tests."
val restRow1 =
ChatMessage(
id = "rest-s-101",
role = MessageRole.ASSISTANT,
content = prefixText,
restId = "rest-s-101",
)
val restRow2 =
ChatMessage(
id = "rest-s-102",
role = MessageRole.ASSISTANT,
content = fullText,
restId = "rest-s-102",
)
assertFalse(sameLogicalMessage(restRow1, restRow2))
assertFalse(sameLogicalMessage(restRow2, restRow1))
val merged = mergeTranscriptWithLive(listOf(restRow2), listOf(restRow1), preserveLiveIds = true)
assertEquals(2, merged.size)
}
// ── Contract 3 ────────────────────────────────────────────────────────────
// TOOL rows never match by content prefix.
@Test
fun contract3_toolRows_neverMatchByContentPrefix() {
val prefixToolContent = """{"output":"build succeeded without any compiler errors or warnings during check"}"""
val fullToolContent =
"""{"output":"build succeeded without any compiler errors or warnings during check and all tests pass"}"""
val tool1 =
ChatMessage(
id = "tool-1",
role = MessageRole.TOOL,
content = prefixToolContent,
toolName = "terminal",
toolCallId = "call-1",
)
val tool2 =
ChatMessage(
id = "tool-2",
role = MessageRole.TOOL,
content = fullToolContent,
toolName = "terminal",
toolCallId = "call-2",
)
assertFalse(sameLogicalMessage(tool1, tool2))
assertFalse(sameLogicalMessage(tool2, tool1))
}
// ── Contract 4 ────────────────────────────────────────────────────────────
// Exact-equal trimmed content still matches as before for a live vs REST pair
// of the same role (sanity).
@Test
fun contract4_exactEqualTrimmedContent_liveVsRest_matchesSameLogicalMessage() {
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT)) {
val live =
ChatMessage(
id = "live-temp-id",
role = role,
content = "\n\nHello, this is an important message for testing.\n ",
)
val rest =
ChatMessage(
id = "rest-s-200",
role = role,
content = "Hello, this is an important message for testing.",
restId = "rest-s-200",
)
assertTrue("Role $role exact-equal trimmed content must match", sameLogicalMessage(live, rest))
assertTrue(
"Role $role exact-equal trimmed content must match symmetrically",
sameLogicalMessage(rest, live),
)
val merged = mergeTranscriptWithLive(listOf(rest), listOf(live), preserveLiveIds = true)
assertEquals("Exact-equal trimmed pair should deduplicate into single message", 1, merged.size)
}
}
// ── Contract 5 ────────────────────────────────────────────────────────────
// Issue #842 scenario still dedupes: a live sealed interim assistant narration
// without completion/REST identity whose text (after trim) is a prefix of /
// equal-prefix to the persisted REST narration -> treated as same (reuse the
// shape used in ChatHistoryCommentaryTest if it exists).
@Test
fun contract5_issue842_liveSealedInterimAssistantNarration_prefixOfRestNarration_stillDedupes() {
// Issue #842 / ChatToolDedupeTest pattern:
// Live sealed interim bubble without REST identity raced flush and ends prematurely.
// Rest row contains the full/completed narration.
val liveOrphan =
ChatMessage(
id = "live-orphan-sealed",
role = MessageRole.ASSISTANT,
content =
"\n\ntool's loaded! 🔍 now searchin' for the **best hummus recipe**" +
" — gimme dat creamy dreamy chickpea",
)
val restRow =
ChatMessage(
id = "rest-s-842",
role = MessageRole.ASSISTANT,
content =
"tool's loaded! 🔍 now searchin' for the **best hummus recipe**" +
" — gimme dat creamy dreamy chickpea goodness:",
restId = "rest-s-842",
)
assertTrue(sameLogicalMessage(liveOrphan, restRow))
assertTrue(sameLogicalMessage(restRow, liveOrphan))
val merged = mergeTranscriptWithLive(listOf(restRow), listOf(liveOrphan), preserveLiveIds = true)
assertEquals(
"Live interim orphan should deduplicate against persisted REST narration",
1,
merged.size,
)
}
}