refactor(chat): pure server message mapper (#1363)

This commit is contained in:
m57 authored and GitHub committed 2026-09-28 19:57:35 +03:00
1 parent cd992aa0b2
commit 9aefc5bfc7
5 files changed
+179 -32

No files matched your search

@@ -1,20 +1,24 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.notification.ReplyNotificationTracker
import com.m57.hermescontrol.notification.ReplyNotificationTarget
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import kotlinx.serialization.json.JsonObject
/**
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
*
* Reads the supplied transcript snapshot and recovers notification identity through
* ReplyNotificationTracker's synchronized lookup. Safe on the history dispatcher;
* it never mutates ViewModel state or dismisses a notification.
* Pure: every input is plain data. Callers resolve the active reply-notification
* target and the gateway media URL builder, so this never touches Android,
* AuthManager, or the notification tracker (#1337).
*
* @param activeReplyTarget the reply notification currently posted, if any; its
* durable server row id reserves that row's completion identity.
* @param mediaUrl builds an authenticated gateway URL for a `MEDIA:` path, or null
* when no gateway is configured.
* @param nowMs fallback timestamp for rows without one.
*/
internal fun mapServerMessages(
sessionId: String,
@@ -24,7 +28,9 @@ internal fun mapServerMessages(
liveMessages: List<ChatMessage>,
isPagingOlder: Boolean = false,
stableRowIds: Boolean = latestPaging,
context: android.content.Context? = null,
activeReplyTarget: ReplyNotificationTarget? = null,
mediaUrl: (path: String) -> String? = { null },
nowMs: Long = System.currentTimeMillis(),
): List<ChatMessage> {
val existingById = liveMessages.associateBy { it.canonicalRestId ?: it.id }
val liveByExactId =
@@ -55,7 +61,7 @@ internal fun mapServerMessages(
if (!isPagingOlder) {
// #129: reserve durable identity before matching any repeated live prose.
// REST-only recovery remains fail-closed when the target row is absent.
val activeTarget = ReplyNotificationTracker.getActiveTarget(context)?.takeIf { it.sessionId == sessionId }
val activeTarget = activeReplyTarget?.takeIf { it.sessionId == sessionId }
val durableTarget = activeTarget?.takeIf { it.serverMessageId != null }
if (durableTarget != null) {
val exactIndex =
@@ -179,7 +185,7 @@ internal fun mapServerMessages(
?.times(1000)
?.toLong()
?: existingById[restId]?.timestamp
?: System.currentTimeMillis()
?: nowMs
val rawContent =
if (role == MessageRole.TOOL && msg.display_metadata != null) {
@@ -229,18 +235,11 @@ internal fun mapServerMessages(
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
val items = HostMediaExtractor.extract(rawContent)
if (items.isNotEmpty()) {
val baseUrl = AuthManager.getBaseUrl()
val token = AuthManager.getToken().orEmpty()
finalContent = HostMediaExtractor.strip(rawContent)
attachments =
items
.mapNotNull { item ->
val url =
GatewayFileClient.buildMediaUrl(
baseUrl,
token,
item.path,
) ?: return@mapNotNull null
val url = mediaUrl(item.path) ?: return@mapNotNull null
Attachment(
uri = url,
name = mediaNameFromPath(item.path),
@@ -21,6 +21,7 @@ import com.m57.hermescontrol.data.model.parseContextBreakdown
import com.m57.hermescontrol.data.model.parseUsageSnapshot
import com.m57.hermescontrol.data.model.reasoningSupport
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall
@@ -38,6 +39,7 @@ import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toAny
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.notification.ReplyNotificationTracker
import com.m57.hermescontrol.notification.captureTurnBoundary
import com.m57.hermescontrol.notification.correlationScopeId
import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptUiState
@@ -2249,6 +2251,10 @@ class ChatViewModel(
// ── Send message ─────────────────────────────────────────────────────
/** Authenticated gateway URL for a host `MEDIA:` path; kept out of the pure mapper (#1337). */
private fun gatewayMediaUrl(path: String): String? =
GatewayFileClient.buildMediaUrl(AuthManager.getBaseUrl(), AuthManager.getToken().orEmpty(), path)
private fun sendScope(): String =
listOf(
AuthManager.getBaseUrl(),
@@ -4242,7 +4248,8 @@ class ChatViewModel(
offset = 0,
latestPaging = latestPaging,
liveMessages = emptyList(),
context = getApplication(),
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()),
mediaUrl = ::gatewayMediaUrl,
)
}
_uiState.update { it.copy(messages = replacementMessages) }
@@ -4736,7 +4743,8 @@ class ChatViewModel(
liveMessages = _uiState.value.messages,
isPagingOlder = true,
stableRowIds = true,
context = getApplication(),
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()),
mediaUrl = ::gatewayMediaUrl,
)
}
if (!valid()) return@launch
@@ -5046,7 +5054,8 @@ class ChatViewModel(
serverOffset,
useLatest,
current,
context = getApplication(),
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()),
mediaUrl = ::gatewayMediaUrl,
)
} ?: return@launch
persistHistoryPage(page, sessionId)
@@ -5503,7 +5512,8 @@ class ChatViewModel(
useLatest,
current,
isPagingOlder = true,
context = getApplication(),
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()),
mediaUrl = ::gatewayMediaUrl,
)
} ?: return@launch
persistHistoryPage(page, sessionId)
@@ -5584,7 +5594,8 @@ class ChatViewModel(
current,
// Sync fetches recent replies, so confirm live completion identities.
isPagingOlder = false,
context = getApplication(),
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()),
mediaUrl = ::gatewayMediaUrl,
)
} ?: return@launch
persistHistoryPage(page, sessionId)