This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user