fix(chat): keep historical cache out of the live transcript tail (#1253)
This commit is contained in:
1 parent
c581fd23d1
commit
94ec95a33c
18 files changed
+805
-18
No files matched your search
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user