fix(chat): reconcile live assistant reply carrying verifier footer with REST echo (#1241) (#1256)

This commit is contained in:
m57 authored and GitHub committed 2026-09-24 22:17:10 +03:00
1 parent 0d4c0b40ad
commit f3921957b5
5 files changed
+310 -3

No files matched your search

@@ -49,6 +49,7 @@ internal fun mapServerMessages(
}
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
val verifierContentByRestIndex = mutableMapOf<Int, String>()
if (!isPagingOlder) {
// #129: reserve durable identity before matching any repeated live prose.
// REST-only recovery remains fail-closed when the target row is absent.
@@ -89,6 +90,27 @@ internal fun mapServerMessages(
remainingWs.removeAt(wsIdx).completionId?.let { wsCompletionIdByRestIndex[i] = it }
}
}
// Fallback: match live assistant containing a file-mutation verifier footer against
// its footer-free REST counterpart (issue #1241).
for (i in messages.indices.reversed()) {
if (remainingWs.isEmpty()) break
if (i in wsCompletionIdByRestIndex || restIdAt(i) in reservedRestIds) continue
val m = messages[i]
if (m.role?.lowercase() in listOf("user", "system", "tool")) continue
val rawContent = m.displayContentText ?: m.contentText
if (rawContent.isBlank()) continue
val canonicalContent =
if (rawContent.contains("MEDIA:")) HostMediaExtractor.strip(rawContent).trim() else rawContent.trim()
val wsIdx = remainingWs.indexOfLast { ChatVerifierFooter.matchesBase(it.content, canonicalContent) }
if (wsIdx >= 0) {
val matchedWs = remainingWs.removeAt(wsIdx)
matchedWs.completionId?.let { wsCompletionIdByRestIndex[i] = it }
// Propagate the richer live footer content to the REST row so canonical mapping keeps it
if (ChatVerifierFooter.split(matchedWs.content) != null) {
verifierContentByRestIndex[i] = matchedWs.content
}
}
}
}
// Reasoning follows the same reserved identities as completions, never a reusable text lookup.
@@ -119,6 +141,19 @@ internal fun mapServerMessages(
val match = remaining.indexOfLast { it.content.trim() == content }
if (match >= 0) reasoningSources[index] = remaining.removeAt(match)
}
for (index in messages.indices.reversed()) {
val row = messages[index]
val displayContent = row.displayContentText ?: row.contentText
if (reasoningSources[index] != null || displayContent.isBlank() ||
row.role?.lowercase() in listOf("user", "system", "tool") ||
liveByExactId[restIdAt(index)]?.completionId != null || index in wsCompletionIdByRestIndex
) {
continue
}
val content = HostMediaExtractor.strip(displayContent).trim()
val match = remaining.indexOfLast { ChatVerifierFooter.matchesBase(it.content, content) }
if (match >= 0) reasoningSources[index] = remaining.removeAt(match)
}
}
val mapped = mutableListOf<ChatMessage>()
@@ -166,6 +201,8 @@ internal fun mapServerMessages(
var finalContent =
if (role == MessageRole.USER) {
stripGatewayAttachedContext(stripGatewaySteerWrapper(rawContent))
} else if (role == MessageRole.ASSISTANT && verifierContentByRestIndex.containsKey(index)) {
verifierContentByRestIndex[index] ?: rawContent
} else {
rawContent
}
@@ -185,6 +185,14 @@ internal class TranscriptComparison(
val tb = trimmed.getOrPut(b.content) { b.content.trim() }
if (a.role == MessageRole.USER && (ta.startsWith("/") || tb.startsWith("/"))) return false
if (ta == tb) return true
if (a.role == MessageRole.ASSISTANT &&
(a.completionId != null || b.completionId != null) &&
a.completionId == b.completionId &&
ta.isNotBlank() && tb.isNotBlank() &&
ChatVerifierFooter.matchesBase(ta, tb)
) {
return true
}
if (a.role == MessageRole.USER &&
captions.getOrPut(ta) { stripAttachmentRefLines(ta) } ==
captions.getOrPut(tb) { stripAttachmentRefLines(tb) }
@@ -250,7 +258,17 @@ internal fun matchTranscriptMessages(
) &&
// Distinct IDs within the same source are separate occurrences, not echoes.
((message.canonicalRestId == null) != (other.canonicalRestId == null)) &&
comparison.same(message, other)
(
comparison.same(message, other) ||
(
allowAssistantContentMatches &&
message.role == MessageRole.ASSISTANT &&
message.content.isNotBlank() && other.content.isNotBlank() &&
message.completionId != null &&
message.completionId == other.completionId &&
ChatVerifierFooter.matchesBase(message.content, other.content)
)
)
}
if (match != null) {
used[match] = true
@@ -341,6 +359,20 @@ internal fun mergeCachedTranscriptPage(
incoming
.mapIndexedNotNull { index, message ->
val match = matches[index] ?: return@mapIndexedNotNull null
val preservedContent =
if (match.role == MessageRole.ASSISTANT &&
ChatVerifierFooter.split(match.content) != null &&
ChatVerifierFooter.matchesBase(match.content, message.content)
) {
match.content
} else if (message.role == MessageRole.ASSISTANT &&
ChatVerifierFooter.split(message.content) != null &&
ChatVerifierFooter.matchesBase(message.content, match.content)
) {
message.content
} else {
null
}
val rich =
when {
// A late historical snapshot may contribute safe metadata, but it must
@@ -367,6 +399,7 @@ internal fun mergeCachedTranscriptPage(
match.id to
rich.copy(
id = match.id,
content = preservedContent ?: rich.content,
restId = match.canonicalRestId ?: message.canonicalRestId,
completionId = match.completionId ?: message.completionId,
displayKind = match.displayKind ?: message.displayKind,
@@ -408,9 +441,18 @@ internal fun mergeTranscriptWithLive(
}
preserveLiveIds && match != null && !match.id.startsWith("rest-") -> {
val mergedContent =
if (match.role == MessageRole.ASSISTANT &&
ChatVerifierFooter.split(match.content) != null &&
ChatVerifierFooter.matchesBase(match.content, message.content)
) {
match.content
} else {
message.content
}
match.copy(
restId = message.canonicalRestId ?: match.canonicalRestId,
content = message.content,
content = mergedContent,
timestamp = message.timestamp,
isStreaming = message.isStreaming,
reasoningText = message.reasoningText.ifBlank { match.reasoningText },
@@ -426,7 +468,21 @@ internal fun mergeTranscriptWithLive(
}
else -> {
message.copy(completionId = message.completionId ?: match?.completionId, isHistoricalCache = false)
val mergedContent =
if (message.role == MessageRole.ASSISTANT &&
match != null &&
ChatVerifierFooter.split(match.content) != null &&
ChatVerifierFooter.matchesBase(match.content, message.content)
) {
match.content
} else {
message.content
}
message.copy(
content = mergedContent,
completionId = message.completionId ?: match?.completionId,
isHistoricalCache = false,
)
}
}
}
@@ -0,0 +1,80 @@
package com.m57.hermescontrol.ui.chat
/**
* Utility to recognize and extract File-mutation verifier footers appended to
* assistant responses on live channels.
*
* Pattern emitted by backend `turn_explainers._format_file_mutation_failure_footer`:
* ⚠️ File-mutation verifier: N file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run `git status` or `read_file` to confirm what actually landed.
* • `path` — [tool] error_preview
* • … and N more
*/
internal object ChatVerifierFooter {
private const val HEADER_PREFIX = "⚠️ File-mutation verifier:"
private const val HEADER_NO_EMOJI_PREFIX = "File-mutation verifier:"
data class SplitResult(
val body: String,
val footer: String,
)
/**
* Splits [content] into (body, footer) if it cleanly ends with a recognized
* File-mutation verifier block. If not, returns null.
*/
fun split(content: String): SplitResult? {
val trimmed = content.trimEnd()
if (trimmed.isBlank()) return null
val headerIdx =
trimmed.lastIndexOf(HEADER_PREFIX).let { idx ->
if (idx >= 0) idx else trimmed.lastIndexOf(HEADER_NO_EMOJI_PREFIX)
}
if (headerIdx <= 0) return null
// Must be preceded by a newline (standalone block)
val charBefore = trimmed[headerIdx - 1]
if (charBefore != '\n') return null
val headerAndRest = trimmed.substring(headerIdx)
val lines = headerAndRest.lines()
val firstLine = lines.first().trim()
// Verify first line structure: "⚠️ File-mutation verifier: ... FAILED this turn..."
if (!firstLine.contains("file edit(s) FAILED this turn") &&
!firstLine.contains("file(s) were NOT modified this turn")
) {
return null
}
// Remaining lines must be bullet items or continuation lines
for (i in 1 until lines.size) {
val line = lines[i]
if (line.isBlank()) continue
val trimmedLine = line.trimStart()
// Bullets typically start with •
if (!trimmedLine.startsWith("•") && !trimmedLine.startsWith("-") && !line.startsWith(" ")) {
return null
}
}
val body = trimmed.substring(0, headerIdx).trimEnd()
if (body.isBlank()) return null
val footer = trimmed.substring(headerIdx)
return SplitResult(body = body, footer = footer)
}
/**
* Returns true if [a] and [b] have the same underlying text once any
* trailing file-mutation verifier footer is removed.
*/
fun matchesBase(
a: String,
b: String,
): Boolean {
val cleanA = split(a)?.body ?: a.trim()
val cleanB = split(b)?.body ?: b.trim()
return cleanA == cleanB
}
}