fix(chat): keep historical cache out of the live transcript tail (#1253)

This commit is contained in:
AlexDev_ authored and GitHub committed 2026-09-24 21:27:39 +03:00
1 parent c581fd23d1
commit 94ec95a33c
18 files changed
+805 -18

No files matched your search

@@ -54,6 +54,8 @@ data class ChatMessageEntity(
val sortGroup: Int = 1,
@ColumnInfo(name = "sort_order", defaultValue = "0")
val sortOrder: Long = 0,
@ColumnInfo(name = "message_provenance", defaultValue = "'UNKNOWN'")
val messageProvenance: String = "UNKNOWN",
)
internal fun ChatMessageEntity.isSessionStartMarker(): Boolean =
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.data.local
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageProvenance
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.ToolStatus
@@ -40,6 +41,9 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
completionId = completionId,
restId = restId,
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 && !isSessionStartMarker() },
messageProvenance =
MessageProvenance.entries.firstOrNull { it.name == messageProvenance }
?: MessageProvenance.UNKNOWN,
)
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
@@ -59,4 +63,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
tps = tps,
completionId = completionId,
restId = restId,
messageProvenance = messageProvenance.name,
)
@@ -14,7 +14,7 @@ import java.io.File
@Database(
entities = [ChatMessageEntity::class],
version = 9,
version = 10,
exportSchema = true,
)
abstract class HermesDatabase : RoomDatabase() {
@@ -115,6 +115,17 @@ abstract class HermesDatabase : RoomDatabase() {
}
}
val MIGRATION_9_10: Migration =
object : Migration(9, 10) {
override suspend fun migrate(connection: SQLiteConnection) {
// v9 cannot prove whether a UUID-only row reached the server. Preserve that
// ambiguity; only new outgoing prompts record LOCAL_PENDING before submit.
connection.execSQL(
"ALTER TABLE chat_messages ADD COLUMN message_provenance TEXT NOT NULL DEFAULT 'UNKNOWN'",
)
}
}
suspend fun get(context: Context): HermesDatabase =
withContext(Dispatchers.IO) {
instance?.let { return@withContext it }
@@ -152,6 +163,7 @@ abstract class HermesDatabase : RoomDatabase() {
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
).fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
@@ -35,6 +35,12 @@ data class ToolOutputRiskData(
val redacted: Boolean,
)
/** Durable evidence about a locally-created row; UNKNOWN must not be treated as proof of delivery. */
enum class MessageProvenance {
UNKNOWN,
LOCAL_PENDING,
}
data class ChatMessage(
val id: String = UUID.randomUUID().toString(),
val role: MessageRole,
@@ -91,12 +97,20 @@ data class ChatMessage(
val restId: String? = null,
/** Cache insertion sequence for unconfirmed local rows; null before first persistence. */
val localOrder: Long? = null,
/** Persisted before prompt submission so process death cannot turn an unsent prompt into old history. */
val messageProvenance: MessageProvenance = MessageProvenance.UNKNOWN,
/** Read from history, not observed live in this view. Never persisted as delivery state. */
val isHistoricalCache: Boolean = false,
)
/** Cached REST rows already carry their canonical identity in the persisted primary key. */
internal val ChatMessage.canonicalRestId: String?
get() = restId ?: id.takeIf { it.startsWith("rest-") }
/** A persisted RUNNING snapshot is not evidence of current tool activity. */
internal val ChatMessage.isToolRunning: Boolean
get() = toolStatus == ToolStatus.RUNNING && !isHistoricalCache
/**
* Single live transcript log entry for subagent execution.
*/
@@ -227,6 +227,17 @@ internal fun mapServerMessages(
val matches = matchTranscriptMessages(mappedTools, liveTools)
val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] }
return mapped.map { message ->
toolsById[message.id]?.copy(restId = message.canonicalRestId) ?: message
val local = toolsById[message.id]
if (local?.isHistoricalCache == true && local.toolStatus == ToolStatus.RUNNING) {
// A canonical tool-result row settles a cached tool.start, not the reverse.
local.copy(
restId = message.canonicalRestId,
content = message.content,
toolStatus = ToolStatus.COMPLETED,
isHistoricalCache = false,
)
} else {
local?.copy(restId = message.canonicalRestId) ?: message
}
}
}
@@ -341,13 +341,35 @@ internal fun mergeCachedTranscriptPage(
incoming
.mapIndexedNotNull { index, message ->
val match = matches[index] ?: return@mapIndexedNotNull null
val rich = if (match.id.startsWith("rest-") && !message.id.startsWith("rest-")) message else match
val rich =
when {
// A late historical snapshot may contribute safe metadata, but it must
// never replace a canonical server payload/status already on screen.
message.isHistoricalCache && match.canonicalRestId != null -> {
match.copy(
toolName = match.toolName ?: message.toolName,
toolCallId = match.toolCallId.ifBlank { message.toolCallId },
attachments = match.attachments ?: message.attachments,
finishTimestamp = match.finishTimestamp ?: message.finishTimestamp,
tokenCount = match.tokenCount ?: message.tokenCount,
tps = match.tps ?: message.tps,
)
}
match.id.startsWith("rest-") && !message.id.startsWith("rest-") -> {
message
}
else -> {
match
}
}
match.id to
rich.copy(
id = match.id,
restId = message.canonicalRestId ?: match.canonicalRestId,
restId = match.canonicalRestId ?: message.canonicalRestId,
completionId = match.completionId ?: message.completionId,
displayKind = message.displayKind ?: match.displayKind,
displayKind = match.displayKind ?: message.displayKind,
)
}.toMap()
val resolvedOrders =
@@ -381,6 +403,7 @@ internal fun mergeTranscriptWithLive(
match.copy(
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
displayKind = message.displayKind ?: match.displayKind,
isHistoricalCache = false,
)
}
@@ -398,11 +421,12 @@ internal fun mergeTranscriptWithLive(
displayKind = message.displayKind ?: match.displayKind,
tokenCount = message.tokenCount ?: match.tokenCount,
completionId = message.completionId ?: match.completionId,
isHistoricalCache = false,
)
}
else -> {
message.copy(completionId = message.completionId ?: match?.completionId)
message.copy(completionId = message.completionId ?: match?.completionId, isHistoricalCache = false)
}
}
}
@@ -431,6 +455,8 @@ private fun List<ChatMessage>.inTranscriptOrder(
resolvedOrders: Map<String, Long>,
): List<ChatMessage> {
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
// Session-start markers use -1 and must remain before unresolved cached history.
val beforeCanonical = (mapNotNull { it.canonicalOrder }.filter { it >= 0L }.minOrNull() ?: 0L) - 1L
var precedingCanonical: Long? = null
var hasPendingPredecessor = false
var pendingLocalOrder: Long? = null
@@ -451,6 +477,10 @@ private fun List<ChatMessage>.inTranscriptOrder(
?: latestCanonical
}
if (hasPendingPredecessor) pendingOrderByLocal[message.id] = pendingLocalOrder ?: Long.MAX_VALUE
} else if (message.isHistoricalCache) {
// Room groups UUID-only rows after all confirmed rows; that predecessor is
// not a chronological anchor. Keep unresolved history before the server window.
localAnchors[message.id] = beforeCanonical
} else {
hasPendingPredecessor = true
pendingLocalOrder = message.localOrder
@@ -1832,6 +1832,7 @@ class ChatViewModel(
content = text,
attachments = if (attachments.isNotEmpty()) attachments else null,
tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 },
messageProvenance = MessageProvenance.LOCAL_PENDING,
)
// Update UI immediately
@@ -1894,6 +1895,7 @@ class ChatViewModel(
content = text,
attachments = if (attachments.isNotEmpty()) attachments else null,
tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 },
messageProvenance = MessageProvenance.LOCAL_PENDING,
)
// Upload attachments then submit prompt
@@ -3256,7 +3258,29 @@ class ChatViewModel(
val snapshot = _uiState.value
val computed =
withContext(historyDispatcher) {
val page = mapPage(snapshot.messages)
val mapped = mapPage(snapshot.messages)
val currentById = snapshot.messages.associateBy { it.id }
// Durable provenance distinguishes new unsent prompts after process death.
// Legacy UUID-only USER rows remain conservatively unconfirmed: UNKNOWN is
// not evidence that the server delivered them.
val page =
if (cached) {
mapped.map { message ->
message.copy(
isHistoricalCache =
when {
currentById[message.id]?.isHistoricalCache == false -> false
message.isPermanentlyLocal() -> false
message.messageProvenance == MessageProvenance.LOCAL_PENDING -> false
message.canonicalRestId != null -> true
message.role == MessageRole.USER -> false
else -> true
},
)
}
} else {
mapped
}
val merged =
if (cached) {
mergeCachedTranscriptPage(page, snapshot.messages)
@@ -681,6 +681,7 @@ object ChatWsEventReducer {
messages[toolIdx].copy(
toolStatus = ToolStatus.COMPLETED,
content = contentJson,
isHistoricalCache = false,
)
messages[toolIdx] = updated
@@ -928,7 +929,11 @@ object ChatWsEventReducer {
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(progressPreview = event.preview ?: "")
messages[toolIdx] =
messages[toolIdx].copy(
progressPreview = event.preview ?: "",
isHistoricalCache = false,
)
return ReducerResult(
state = state.copy(messages = messages),
streamingState = streamingState,
@@ -944,14 +949,14 @@ object ChatWsEventReducer {
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(progressPreview = "")
messages[toolIdx] = messages[toolIdx].copy(progressPreview = "", isHistoricalCache = false)
return ReducerResult(
state = state.copy(messages = messages),
streamingState = streamingState,
)
}
/** Match stable tool IDs first, with name matching for legacy events. */
/** Match an explicit stable ID exclusively; use name matching only when legacy events omit the ID. */
private fun findToolIndex(
messages: List<ChatMessage>,
name: String?,
@@ -965,7 +970,8 @@ object ChatWsEventReducer {
it.toolCallId == toolId &&
it.toolStatus == status
}
if (byId >= 0) return byId
// An explicit identity must never claim a different historical invocation.
return byId
}
return messages.indexOfLast {
@@ -143,8 +143,8 @@ internal fun ToolBubble(
val statusColors = LocalHermesStatusColors.current
val view =
remember(message.content, message.toolName, message.toolStatus) {
parseToolOutput(message.content, message.toolName, message.toolStatus == ToolStatus.RUNNING)
remember(message.content, message.toolName, message.isToolRunning) {
parseToolOutput(message.content, message.toolName, message.isToolRunning)
}
val config = ToolSchemaRegistry.getDisplayConfig(message.toolName)
@@ -182,7 +182,7 @@ internal fun ToolBubble(
HeaderRow(message, config, contentColor, statusColors)
// ── Tool progress preview (tool.progress) ──
if (message.toolStatus == ToolStatus.RUNNING && !message.progressPreview.isNullOrEmpty()) {
if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) {
Text(
text = message.progressPreview,
style =
@@ -534,7 +534,7 @@ private fun HeaderRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Status icon or spinner
if (message.toolStatus == ToolStatus.RUNNING) {
if (message.isToolRunning) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
@@ -20,8 +20,8 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry
import com.m57.hermescontrol.ui.chat.ToolStatus
import com.m57.hermescontrol.ui.chat.components.TypingIndicator
import com.m57.hermescontrol.ui.chat.isToolRunning
import com.m57.hermescontrol.ui.chat.isUserTurnBoundary
/** Live status for an agent turn before visible assistant prose is available. */
@@ -61,7 +61,7 @@ internal fun deriveAgentStatus(
.asReversed()
.firstOrNull {
it.role == MessageRole.TOOL &&
it.toolStatus == ToolStatus.RUNNING
it.isToolRunning
}
val hasVisibleStreamingContent = streamingState.streamingMessage?.content?.isNotBlank() == true