feat(chat): show agent message reactions (#1422)
This commit is contained in:
14 files changed
+289
No files matched your search
@@ -0,0 +1,33 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/** One emoji reaction on a message. Tapback semantics: at most one per [author] per message. */
|
||||||
|
data class MessageReaction(
|
||||||
|
val emoji: String,
|
||||||
|
val author: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Reactions carried by a live `message.reaction` event payload (`reactions` list of maps). */
|
||||||
|
internal fun parseMessageReactions(raw: Any?): List<MessageReaction> =
|
||||||
|
(raw as? List<*>).orEmpty().mapNotNull { item ->
|
||||||
|
val map = item as? Map<*, *> ?: return@mapNotNull null
|
||||||
|
val emoji = (map["emoji"] as? String)?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||||
|
MessageReaction(emoji = emoji, author = map["author"] as? String ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reactions persisted under `display_metadata.reactions` on a REST transcript row. */
|
||||||
|
internal fun parseMessageReactions(displayMetadata: JsonElement?): List<MessageReaction> {
|
||||||
|
val list = (displayMetadata as? JsonObject)?.get("reactions") as? JsonArray ?: return emptyList()
|
||||||
|
return list.mapNotNull { item ->
|
||||||
|
val obj = item as? JsonObject ?: return@mapNotNull null
|
||||||
|
val emoji =
|
||||||
|
(obj["emoji"] as? JsonPrimitive)?.takeIf { it.isString }?.content?.takeIf { it.isNotBlank() }
|
||||||
|
?: return@mapNotNull null
|
||||||
|
val author = (obj["author"] as? JsonPrimitive)?.takeIf { it.isString }?.content.orEmpty()
|
||||||
|
MessageReaction(emoji = emoji, author = author)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,10 @@ data class SessionMessage(
|
|||||||
val timestampText: String?
|
val timestampText: String?
|
||||||
get() = (timestamp as? JsonPrimitive)?.content
|
get() = (timestamp as? JsonPrimitive)?.content
|
||||||
|
|
||||||
|
/** Emoji reactions persisted on this row (`display_metadata.reactions`). */
|
||||||
|
val reactions: List<MessageReaction>
|
||||||
|
get() = parseMessageReactions(display_metadata)
|
||||||
|
|
||||||
val tokenCount: Int?
|
val tokenCount: Int?
|
||||||
get() {
|
get() {
|
||||||
val prim = token_count as? JsonPrimitive ?: return null
|
val prim = token_count as? JsonPrimitive ?: return null
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.m57.hermescontrol.data.ws
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.m57.hermescontrol.data.model.parseMessageReactions
|
||||||
import com.m57.hermescontrol.ui.chat.extractTodosFromMap
|
import com.m57.hermescontrol.ui.chat.extractTodosFromMap
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
@@ -333,6 +334,20 @@ object EventParser {
|
|||||||
WsEvent.ReactionEvent(kind)
|
WsEvent.ReactionEvent(kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"message.reaction" -> {
|
||||||
|
val rowId = (payload?.get("row_id") as? Number)?.toLong()
|
||||||
|
if (rowId == null) {
|
||||||
|
WsEvent.Unknown(rawJson)
|
||||||
|
} else {
|
||||||
|
WsEvent.MessageReactionUpdated(
|
||||||
|
rowId = rowId,
|
||||||
|
reactions = parseMessageReactions(payload["reactions"]),
|
||||||
|
role = payload["role"] as? String ?: "",
|
||||||
|
sessionId = sessionId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
"approval.request" -> {
|
"approval.request" -> {
|
||||||
val command = payload?.get("command") as? String
|
val command = payload?.get("command") as? String
|
||||||
val description = payload?.get("description") as? String
|
val description = payload?.get("description") as? String
|
||||||
|
|||||||
@@ -445,6 +445,18 @@ sealed class WsEvent {
|
|||||||
val kind: String = "",
|
val kind: String = "",
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `message.reaction`: the agent (or another client) set/cleared a tapback on a stored
|
||||||
|
* message. [reactions] is the row's full list after the write; [rowId] is the gateway
|
||||||
|
* SQLite row id, matched against `ChatMessage.serverRowId`.
|
||||||
|
*/
|
||||||
|
data class MessageReactionUpdated(
|
||||||
|
val rowId: Long,
|
||||||
|
val reactions: List<com.m57.hermescontrol.data.model.MessageReaction>,
|
||||||
|
val role: String,
|
||||||
|
val sessionId: String?,
|
||||||
|
) : WsEvent()
|
||||||
|
|
||||||
// ── Replay resync (internal) ──────────────────────────────────────────
|
// ── Replay resync (internal) ──────────────────────────────────────────
|
||||||
|
|
||||||
/** Internal: replay could not cover the reconnect gap (truncated or epoch change) — UI must refetch history. */
|
/** Internal: replay could not cover the reconnect gap (truncated or epoch change) — UI must refetch history. */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import com.m57.hermescontrol.data.model.MessageReaction
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,6 +114,8 @@ data class ChatMessage(
|
|||||||
* Scoped to the owning profile store; transient.
|
* Scoped to the owning profile store; transient.
|
||||||
*/
|
*/
|
||||||
val serverRowId: Long? = null,
|
val serverRowId: Long? = null,
|
||||||
|
/** Emoji tapbacks on this message (REST `display_metadata` or live `message.reaction`); transient. */
|
||||||
|
val reactions: List<MessageReaction> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Cached REST rows already carry their canonical identity in the persisted primary key. */
|
/** Cached REST rows already carry their canonical identity in the persisted primary key. */
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ internal fun mapServerMessages(
|
|||||||
tokenCount = tokenCount,
|
tokenCount = tokenCount,
|
||||||
completionId = completionId,
|
completionId = completionId,
|
||||||
serverRowId = msg.id?.toLong()?.takeIf { it > 0L },
|
serverRowId = msg.id?.toLong()?.takeIf { it > 0L },
|
||||||
|
reactions = msg.reactions,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ internal fun dedupeCachedMessages(
|
|||||||
message.copy(
|
message.copy(
|
||||||
restId = it.canonicalRestId,
|
restId = it.canonicalRestId,
|
||||||
serverRowId = message.serverRowId ?: it.serverRowId,
|
serverRowId = message.serverRowId ?: it.serverRowId,
|
||||||
|
reactions = it.reactions.ifEmpty { message.reactions },
|
||||||
completionId = message.completionId ?: it.completionId,
|
completionId = message.completionId ?: it.completionId,
|
||||||
displayKind = message.displayKind ?: it.displayKind,
|
displayKind = message.displayKind ?: it.displayKind,
|
||||||
isRestoredUnconfirmed = false,
|
isRestoredUnconfirmed = false,
|
||||||
@@ -419,6 +420,7 @@ internal fun mergeCachedTranscriptPage(
|
|||||||
content = preservedContent ?: rich.content,
|
content = preservedContent ?: rich.content,
|
||||||
restId = match.canonicalRestId ?: message.canonicalRestId,
|
restId = match.canonicalRestId ?: message.canonicalRestId,
|
||||||
serverRowId = match.serverRowId ?: message.serverRowId,
|
serverRowId = match.serverRowId ?: message.serverRowId,
|
||||||
|
reactions = message.reactions.ifEmpty { match.reactions },
|
||||||
completionId = match.completionId ?: message.completionId,
|
completionId = match.completionId ?: message.completionId,
|
||||||
displayKind = match.displayKind ?: message.displayKind,
|
displayKind = match.displayKind ?: message.displayKind,
|
||||||
isRestoredUnconfirmed =
|
isRestoredUnconfirmed =
|
||||||
@@ -457,6 +459,7 @@ internal fun mergeTranscriptWithLive(
|
|||||||
match.copy(
|
match.copy(
|
||||||
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
||||||
serverRowId = message.serverRowId ?: match.serverRowId,
|
serverRowId = message.serverRowId ?: match.serverRowId,
|
||||||
|
reactions = message.reactions.ifEmpty { match.reactions },
|
||||||
displayKind = message.displayKind ?: match.displayKind,
|
displayKind = message.displayKind ?: match.displayKind,
|
||||||
isHistoricalCache = false,
|
isHistoricalCache = false,
|
||||||
isRestoredUnconfirmed = false,
|
isRestoredUnconfirmed = false,
|
||||||
@@ -476,6 +479,7 @@ internal fun mergeTranscriptWithLive(
|
|||||||
match.copy(
|
match.copy(
|
||||||
restId = message.canonicalRestId ?: match.canonicalRestId,
|
restId = message.canonicalRestId ?: match.canonicalRestId,
|
||||||
serverRowId = message.serverRowId ?: match.serverRowId,
|
serverRowId = message.serverRowId ?: match.serverRowId,
|
||||||
|
reactions = message.reactions.ifEmpty { match.reactions },
|
||||||
content = mergedContent,
|
content = mergedContent,
|
||||||
timestamp = message.timestamp,
|
timestamp = message.timestamp,
|
||||||
isStreaming = message.isStreaming,
|
isStreaming = message.isStreaming,
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.BtwComplete -> event.sessionId
|
is WsEvent.BtwComplete -> event.sessionId
|
||||||
is WsEvent.SessionUsage -> event.sessionId
|
is WsEvent.SessionUsage -> event.sessionId
|
||||||
is WsEvent.TranscriptResyncRequired -> event.sessionId
|
is WsEvent.TranscriptResyncRequired -> event.sessionId
|
||||||
|
is WsEvent.MessageReactionUpdated -> event.sessionId
|
||||||
is WsEvent.VaultUnlockRequest -> event.sessionId
|
is WsEvent.VaultUnlockRequest -> event.sessionId
|
||||||
is WsEvent.VaultUnlockExpire -> event.sessionId
|
is WsEvent.VaultUnlockExpire -> event.sessionId
|
||||||
is WsEvent.VaultSaveLoginRequest -> event.sessionId
|
is WsEvent.VaultSaveLoginRequest -> event.sessionId
|
||||||
@@ -252,6 +253,8 @@ object ChatWsEventReducer {
|
|||||||
// ReactionEvent is handled by the ViewModel — purely cosmetic animation
|
// ReactionEvent is handled by the ViewModel — purely cosmetic animation
|
||||||
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
|
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
|
is WsEvent.MessageReactionUpdated -> onMessageReaction(state, streamingState, event)
|
||||||
|
|
||||||
// Change events (issue #784) are consumed by their screens' ViewModels
|
// Change events (issue #784) are consumed by their screens' ViewModels
|
||||||
is WsEvent.ChangeEvent -> ReducerResult(state = state, streamingState = streamingState)
|
is WsEvent.ChangeEvent -> ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
@@ -1079,6 +1082,22 @@ object ChatWsEventReducer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paints a live tapback on the row whose gateway id matches; unmatched rows arrive with the next REST load. */
|
||||||
|
private fun onMessageReaction(
|
||||||
|
state: ChatUiState,
|
||||||
|
streamingState: StreamingState,
|
||||||
|
event: WsEvent.MessageReactionUpdated,
|
||||||
|
): ReducerResult {
|
||||||
|
if (state.messages.none { it.serverRowId == event.rowId }) {
|
||||||
|
return ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
}
|
||||||
|
val messages =
|
||||||
|
state.messages.map {
|
||||||
|
if (it.serverRowId == event.rowId) it.copy(reactions = event.reactions) else it
|
||||||
|
}
|
||||||
|
return ReducerResult(state = state.copy(messages = messages), streamingState = streamingState)
|
||||||
|
}
|
||||||
|
|
||||||
private fun onUnknown(
|
private fun onUnknown(
|
||||||
state: ChatUiState,
|
state: ChatUiState,
|
||||||
streamingState: StreamingState,
|
streamingState: StreamingState,
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.data.model.MessageReaction
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tapback chips shown under a message. One reaction per author, so a short row is enough;
|
||||||
|
* renders nothing when [reactions] is empty.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MessageReactionChips(
|
||||||
|
reactions: List<MessageReaction>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (reactions.isEmpty()) return
|
||||||
|
Row(
|
||||||
|
modifier = modifier.testTag("message_reactions"),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
reactions.forEach { reaction ->
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = reaction.emoji,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ import com.m57.hermescontrol.ui.chat.InlineAttachment
|
|||||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||||
import com.m57.hermescontrol.ui.chat.MessageSegment
|
import com.m57.hermescontrol.ui.chat.MessageSegment
|
||||||
import com.m57.hermescontrol.ui.chat.TokenEstimator
|
import com.m57.hermescontrol.ui.chat.TokenEstimator
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.MessageReactionChips
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
|
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
|
||||||
import com.m57.hermescontrol.ui.chat.splitByMedia
|
import com.m57.hermescontrol.ui.chat.splitByMedia
|
||||||
@@ -143,6 +144,10 @@ internal fun FullBleedAgentMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!message.isStreaming) {
|
||||||
|
MessageReactionChips(message.reactions, Modifier.padding(top = 2.dp))
|
||||||
|
}
|
||||||
|
|
||||||
if (!message.isStreaming && message.content.isNotBlank()) {
|
if (!message.isStreaming && message.content.isNotBlank()) {
|
||||||
val showTokenStat =
|
val showTokenStat =
|
||||||
messageStatsEnabled && showAssistantMessageTokens &&
|
messageStatsEnabled && showAssistantMessageTokens &&
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import com.m57.hermescontrol.ui.chat.UserBubble
|
|||||||
import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch
|
import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
|
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
|
||||||
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
|
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.MessageReactionChips
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||||
import com.m57.hermescontrol.ui.chat.components.VaultCodeCard
|
import com.m57.hermescontrol.ui.chat.components.VaultCodeCard
|
||||||
import com.m57.hermescontrol.ui.chat.components.VaultSaveLoginCard
|
import com.m57.hermescontrol.ui.chat.components.VaultSaveLoginCard
|
||||||
@@ -311,6 +312,15 @@ fun FullBleedChatList(
|
|||||||
messageStatsEnabled = messageStatsEnabled,
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
showUserMessageTokens = showUserMessageTokens,
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
)
|
)
|
||||||
|
if (userMessage.reactions.isNotEmpty()) {
|
||||||
|
MessageReactionChips(
|
||||||
|
reactions = userMessage.reactions,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.data.ws
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.MessageReaction
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
@@ -928,6 +929,52 @@ class EventParserTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseMessageReaction_returnsMessageReactionUpdated() {
|
||||||
|
val response =
|
||||||
|
createJsonRpcResponse(
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id = null,
|
||||||
|
result = null,
|
||||||
|
error = null,
|
||||||
|
method = "event",
|
||||||
|
params =
|
||||||
|
mapOf(
|
||||||
|
"type" to "message.reaction",
|
||||||
|
"session_id" to "s1",
|
||||||
|
"payload" to
|
||||||
|
mapOf(
|
||||||
|
"row_id" to 42,
|
||||||
|
"role" to "user",
|
||||||
|
"reactions" to
|
||||||
|
listOf(
|
||||||
|
mapOf("emoji" to "\uD83D\uDE02", "author" to "agent", "at" to 1.0),
|
||||||
|
mapOf("emoji" to "", "author" to "user"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val event = EventParser.parse(response, "") as WsEvent.MessageReactionUpdated
|
||||||
|
assertEquals(42L, event.rowId)
|
||||||
|
assertEquals("user", event.role)
|
||||||
|
assertEquals("s1", event.sessionId)
|
||||||
|
assertEquals(listOf(MessageReaction("\uD83D\uDE02", "agent")), event.reactions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseMessageReaction_withoutRowId_isUnknown() {
|
||||||
|
val response =
|
||||||
|
createJsonRpcResponse(
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id = null,
|
||||||
|
result = null,
|
||||||
|
error = null,
|
||||||
|
method = "event",
|
||||||
|
params = mapOf("type" to "message.reaction", "payload" to mapOf("reactions" to emptyList<Any>())),
|
||||||
|
)
|
||||||
|
assertTrue(EventParser.parse(response, "") is WsEvent.Unknown)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testParseUnknownType_returnsUnknownEvent() {
|
fun testParseUnknownType_returnsUnknownEvent() {
|
||||||
val response =
|
val response =
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.MessageReaction
|
||||||
import com.m57.hermescontrol.data.model.SessionMessage
|
import com.m57.hermescontrol.data.model.SessionMessage
|
||||||
import com.m57.hermescontrol.notification.ReplyNotificationTarget
|
import com.m57.hermescontrol.notification.ReplyNotificationTarget
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -59,4 +63,35 @@ class ChatServerMessageMapperPurityTest {
|
|||||||
|
|
||||||
assertEquals(42L, mapped.single().timestamp)
|
assertEquals(42L, mapped.single().timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun persistedReactionsHydrateFromDisplayMetadata() {
|
||||||
|
val row =
|
||||||
|
SessionMessage(
|
||||||
|
id = 5,
|
||||||
|
role = "user",
|
||||||
|
content = JsonPrimitive("hello"),
|
||||||
|
display_metadata =
|
||||||
|
buildJsonObject {
|
||||||
|
put(
|
||||||
|
"reactions",
|
||||||
|
buildJsonArray {
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("emoji", "\uD83D\uDE02")
|
||||||
|
put("author", "agent")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(buildJsonObject { put("author", "user") })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val mapped = mapServerMessages("s", listOf(row), 0, true, emptyList()).single()
|
||||||
|
assertEquals(listOf(MessageReaction("\uD83D\uDE02", "agent")), mapped.reactions)
|
||||||
|
assertEquals(
|
||||||
|
emptyList<MessageReaction>(),
|
||||||
|
mapServerMessages("s", listOf(assistant(6, "x")), 0, true, emptyList()).single().reactions,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.MessageReaction
|
||||||
import com.m57.hermescontrol.data.model.UsageSnapshotResponse
|
import com.m57.hermescontrol.data.model.UsageSnapshotResponse
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -1642,4 +1643,60 @@ class ChatWsEventReducerTest {
|
|||||||
assertEquals("idle", result.state.compressionStatus)
|
assertEquals("idle", result.state.compressionStatus)
|
||||||
assertEquals(initialMessages, result.state.messages)
|
assertEquals(initialMessages, result.state.messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun messageReactionPaintsOnlyTheRowWithTheMatchingServerId() {
|
||||||
|
val target = ChatMessage(id = "a", role = MessageRole.USER, content = "hi", serverRowId = 7L)
|
||||||
|
val other = ChatMessage(id = "b", role = MessageRole.ASSISTANT, content = "yo", serverRowId = 8L)
|
||||||
|
val state = ChatUiState(currentSessionId = "s", messages = listOf(target, other))
|
||||||
|
val reactions = listOf(MessageReaction("\u2764\uFE0F", "agent"))
|
||||||
|
val result =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
state,
|
||||||
|
StreamingState(),
|
||||||
|
WsEvent.MessageReactionUpdated(7L, reactions, "user", "s"),
|
||||||
|
"s",
|
||||||
|
)
|
||||||
|
assertEquals(reactions, result.state.messages[0].reactions)
|
||||||
|
assertTrue(
|
||||||
|
result.state.messages[1]
|
||||||
|
.reactions
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
val retracted =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
result.state,
|
||||||
|
result.streamingState,
|
||||||
|
WsEvent.MessageReactionUpdated(7L, emptyList(), "user", "s"),
|
||||||
|
"s",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
retracted.state.messages[0]
|
||||||
|
.reactions
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun messageReactionForUnknownRowOrOtherSessionIsIgnored() {
|
||||||
|
val msg = ChatMessage(id = "a", role = MessageRole.USER, content = "hi", serverRowId = 7L)
|
||||||
|
val state = ChatUiState(currentSessionId = "s", messages = listOf(msg))
|
||||||
|
val reactions = listOf(MessageReaction("\uD83D\uDC4D", "agent"))
|
||||||
|
val unknownRow =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
state,
|
||||||
|
StreamingState(),
|
||||||
|
WsEvent.MessageReactionUpdated(99L, reactions, "user", "s"),
|
||||||
|
"s",
|
||||||
|
)
|
||||||
|
assertEquals(state, unknownRow.state)
|
||||||
|
val otherSession =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
state,
|
||||||
|
StreamingState(),
|
||||||
|
WsEvent.MessageReactionUpdated(7L, reactions, "user", "zzz"),
|
||||||
|
"s",
|
||||||
|
)
|
||||||
|
assertEquals(state, otherSession.state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user