feat(bots): add multi-agent group chat collaboration room (#998)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-28 17:50:37 +03:00
1 parent 6bc4e872ca
commit f04d878358
16 files changed
+1589 -126

No files matched your search

@@ -155,6 +155,14 @@ private fun appEntryProvider(
onBack = { NavigationController.goBack() },
)
}
// ── Multi-Agent Group Chat Room ─────────────────────────────────────
entry<GroupChatKey> { key ->
com.m57.hermescontrol.ui.bots.group.GroupChatScreen(
groupName = key.groupName,
onBack = { NavigationController.goBack() },
)
}
}
@Composable
@@ -23,6 +23,10 @@ import kotlinx.serialization.Serializable
@Serializable data object BotsScreen : NavKey
@Serializable data class GroupChatKey(
val groupName: String,
) : NavKey
@Serializable data object ToolsetsScreen : NavKey
@Serializable data class ToolsetDetailKey(
@@ -3,7 +3,10 @@ package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonPrimitive
@Serializable
data class ProfilesResponse(
@@ -121,12 +124,42 @@ data class GroupChatSyncSnapshot(
@Serializable
data class GroupChatRoomMeta(
val id: String,
val id: String? = null,
val roomId: String? = null,
val name: String? = null,
val members: List<String>? = null,
val members: List<JsonElement>? = null,
val picture: String? = null,
val image: String? = null,
val log: List<GroupChatSyncLogEntry>? = null,
val revision: Long? = null,
val updatedAt: Long? = null,
val createdAt: Long? = null,
) {
val memberNames: List<String>
get() =
members?.mapNotNull { el ->
when (el) {
is JsonPrimitive -> el.content
is JsonObject -> el["name"]?.jsonPrimitive?.content
else -> null
}
}.orEmpty()
}
@Serializable
data class GroupChatSyncLogEntry(
val id: String? = null,
val from: GroupChatSyncFrom? = null,
val text: String? = null,
val at: Long? = null,
val thread: String? = null,
)
@Serializable
data class GroupChatSyncFrom(
val kind: String? = null, // "user" | "member"
val name: String? = null,
val source: String? = null,
)
@Serializable
@@ -400,20 +400,9 @@ fun BotsScreen(
group = group,
onClick = {
if (group.members.isNotEmpty()) {
scope.launch {
val primary = group.members.first()
viewModel.selectBot(primary)
val canonicalId =
primary.canonical_session?.resolved_id
?: primary.canonical_session?.id
if (!canonicalId.isNullOrBlank()) {
NavigationController.openChatSession(canonicalId)
} else {
NavigationController.navigateTo(
com.m57.hermescontrol.ChatScreen,
)
}
}
NavigationController.navigateTo(
com.m57.hermescontrol.GroupChatKey(group.name),
)
}
},
onDisbandClick = {
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -142,9 +143,14 @@ fun CreateBotDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AVAILABLE_SHAPES.forEach { shape ->
FilterChip(
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -173,9 +174,14 @@ fun EditBotBottomSheet(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AVAILABLE_SHAPES.forEach { shape ->
FilterChip(
@@ -0,0 +1,111 @@
package com.m57.hermescontrol.ui.bots.group
import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.ProfileInfo
/**
* Single message entry in a multi-agent group room.
*/
data class GroupChatMessage(
val id: String,
val senderName: String,
val senderDisplayName: String,
val isUser: Boolean,
val avatarMeta: BotAvatarMeta? = null,
val text: String,
val timestamp: Long = System.currentTimeMillis(),
val isStreaming: Boolean = false,
val thread: String? = null,
)
/**
* Pure helper for mention extraction and responder resolution matching Desktop parity.
*/
object GroupChatMentions {
data class MentionResult(
val isEveryone: Boolean,
val mentionedBots: Set<String>,
)
/**
* Parse `@botname`, `@all`, `@everyone` from text against the member roster.
*/
fun parseMentions(
text: String,
members: List<ProfileInfo>,
): MentionResult {
val lowerText = text.lowercase()
val isEveryone = lowerText.contains("@all") || lowerText.contains("@everyone")
val mentioned = mutableSetOf<String>()
for (member in members) {
val handle = member.name.lowercase()
val titleHandle = member.effectiveTitle.lowercase().replace(" ", "")
if (lowerText.contains("@$handle") || lowerText.contains("@$titleHandle")) {
mentioned.add(member.name)
}
}
return MentionResult(
isEveryone = isEveryone,
mentionedBots = mentioned,
)
}
/**
* Determine which bots should take a turn. If @all or no specific mention, all members participate.
*/
fun resolveResponders(
text: String,
members: List<ProfileInfo>,
): List<ProfileInfo> {
val parsed = parseMentions(text, members)
return if (parsed.isEveryone || parsed.mentionedBots.isEmpty()) {
members
} else {
members.filter { parsed.mentionedBots.contains(it.name) }
}
}
/**
* Build the room delta prompt injected into each bot's session.
*/
fun buildTurnPrompt(
groupName: String,
viewer: ProfileInfo,
peers: List<ProfileInfo>,
recentLog: List<GroupChatMessage>,
): String {
val viewerHandle = viewer.name
val peerNames = peers.joinToString(", ") { "@${it.name}" }
val lines =
recentLog.takeLast(10).joinToString("\n") { msg ->
val speaker = if (msg.isUser) "User" else "@${msg.senderName}"
" $speaker: ${msg.text}"
}
val lastUserMsg = recentLog.lastOrNull { it.isUser }?.text.orEmpty()
val parsed = parseMentions(lastUserMsg, peers + viewer)
val isDirectOrAll = parsed.isEveryone || parsed.mentionedBots.contains(viewer.name)
val actionGuidance =
if (isDirectOrAll) {
"- You were explicitly addressed (via @$viewerHandle or @all). Do NOT pass — reply conversationally with your greeting, insight, answer, or status."
} else {
"- If you have nothing new to add, reply with exactly \"(pass)\". Passing is good and lets the conversation settle."
}
return """
[Group chat: "$groupName"] You are @$viewerHandle, one participant in a group chat with $peerNames and the user.
Recent messages in the room:
$lines
Rules for this room:
- Reply with ONE conversational message (1-3 sentences).
$actionGuidance
- Mention a teammate as @name if you want to pull them in.
- Your reply goes to the room verbatim — no preamble.
""".trimIndent()
}
}
@@ -0,0 +1,341 @@
package com.m57.hermescontrol.ui.bots.group
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.common.BotAvatar
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
@Composable
fun GroupChatScreen(
groupName: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: GroupChatViewModel = viewModel { GroupChatViewModel() },
) {
val state by viewModel.uiState.collectAsState()
val listState = rememberLazyListState()
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(""))
}
var isFocused by remember { mutableStateOf(false) }
val handleSend = {
val text = inputFieldValue.text
if (text.isNotBlank()) {
viewModel.sendMessage(text)
inputFieldValue = TextFieldValue("")
}
}
LaunchedEffect(groupName) {
viewModel.setGroup(groupName)
}
LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) {
listState.animateScrollToItem(state.messages.size - 1)
}
}
HermesScaffold(
title = {
Column {
Text(
text = state.groupName.ifBlank { groupName },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
if (state.members.isNotEmpty()) {
Text(
text = stringResource(R.string.group_chat_members_count, state.members.size),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = NavIcon.Back(onBack),
modifier = modifier,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(bottom = 8.dp)
.imePadding(),
) {
when {
state.isLoading -> {
Box(
modifier =
Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(32.dp))
}
}
state.messages.isEmpty() -> {
EmptyState(
title = stringResource(R.string.group_chat_empty_title),
subtitle = stringResource(R.string.group_chat_empty_subtitle),
modifier =
Modifier
.weight(1f)
.fillMaxWidth(),
)
}
else -> {
LazyColumn(
state = listState,
modifier =
Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
items = state.messages,
key = { it.id },
) { message ->
GroupMessageCard(message = message)
}
state.activeSpeaker?.let { speaker ->
item(key = "active_speaker") {
Row(
modifier = Modifier.padding(start = 8.dp, top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.group_chat_thinking, speaker),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
// ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ──
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
shape = RoundedCornerShape(20.dp),
border =
BorderStroke(
width = if (isFocused) 2.dp else 1.dp,
color =
if (isFocused) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
},
),
color = MaterialTheme.colorScheme.surface,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.weight(1f)) {
if (inputFieldValue.text.isEmpty()) {
Text(
text =
stringResource(
R.string.group_chat_composer_hint,
state.groupName.ifBlank { groupName },
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
BasicTextField(
value = inputFieldValue,
onValueChange = { inputFieldValue = it },
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 24.dp, max = 120.dp)
.onFocusChanged { isFocused = it.isFocused }
.testTag("group_chat_input"),
textStyle =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
singleLine = false,
maxLines = 4,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { handleSend() }),
)
}
Spacer(modifier = Modifier.width(4.dp))
IconButton(
onClick = handleSend,
enabled = inputFieldValue.text.isNotBlank(),
colors = IconButtonDefaults.filledTonalIconButtonColors(),
modifier =
Modifier
.size(36.dp)
.testTag("group_chat_send_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
modifier = Modifier.size(18.dp),
)
}
}
}
}
}
}
@Composable
private fun GroupMessageCard(
message: GroupChatMessage,
modifier: Modifier = Modifier,
) {
if (message.isUser) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth(0.85f),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = message.text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
} else {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
) {
BotAvatar(
name = message.senderName,
avatar = message.avatarMeta,
size = 32.dp,
showPresence = false,
)
Spacer(modifier = Modifier.width(8.dp))
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.weight(1f, fill = false),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = message.senderDisplayName,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(4.dp))
if (message.isStreaming && message.text.isBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.5.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(R.string.group_chat_typing),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
} else if (message.text.isNotBlank()) {
MarkdownText(
text = message.text,
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
@@ -0,0 +1,578 @@
package com.m57.hermescontrol.ui.bots.group
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.GroupChatRoomMeta
import com.m57.hermescontrol.data.model.GroupChatSyncFrom
import com.m57.hermescontrol.data.model.GroupChatSyncLogEntry
import com.m57.hermescontrol.data.model.GroupChatSyncSnapshot
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toJsonElement
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonPrimitive
import java.util.UUID
data class GroupChatUiState(
val groupName: String = "",
val members: List<ProfileInfo> = emptyList(),
val messages: List<GroupChatMessage> = emptyList(),
val activeSpeaker: String? = null,
val isLoading: Boolean = true,
val errorMessage: String? = null,
)
data class MemberSession(
val runtimeSessionId: String,
val storedSessionId: String? = null,
)
class GroupChatViewModel(
private var groupName: String = "",
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val json: Json = Json { ignoreUnknownKeys = true },
) : ViewModel() {
private val _uiState = MutableStateFlow(GroupChatUiState(groupName = groupName))
val uiState: StateFlow<GroupChatUiState> = _uiState.asStateFlow()
// Map of bot.name -> active session info in this group
private val memberSessions = mutableMapOf<String, MemberSession>()
// In-flight turn completion deferreds keyed by session_id
private val inFlightTurns = mutableMapOf<String, CompletableDeferred<String>>()
// Map of session_id -> active streaming message ID
private val activeStreamMsgId = mutableMapOf<String, String>()
// Map of session_id -> bot profile
private val sessionToBot = mutableMapOf<String, ProfileInfo>()
init {
if (groupName.isNotBlank()) {
loadGroup()
}
observeWsEvents()
}
/**
* Point the shared instance at [name] and drop all state from the
* previously viewed group chat so one group's data never bleeds into another.
*/
fun setGroup(name: String) {
if (name == groupName && _uiState.value.groupName.isNotBlank()) return
groupName = name
memberSessions.clear()
inFlightTurns.clear()
activeStreamMsgId.clear()
sessionToBot.clear()
_uiState.value = GroupChatUiState(groupName = name)
loadGroup()
}
private fun observeWsEvents() {
viewModelScope.launch(ioDispatcher) {
HermesWsClient.events.collect { event ->
when (event) {
is WsEvent.MessageToken -> {
val sid = event.sessionId?.trim('\"', ' ')
if (sid != null) {
val streamId = activeStreamMsgId[sid]
val bot = sessionToBot[sid]
if (streamId != null && bot != null && event.token.isNotEmpty()) {
_uiState.update { state ->
val existing = state.messages.find { it.id == streamId }
val updatedMessages =
if (existing != null) {
state.messages.map { msg ->
if (msg.id == streamId) {
msg.copy(text = msg.text + event.token)
} else {
msg
}
}
} else {
state.messages +
GroupChatMessage(
id = streamId,
senderName = bot.name,
senderDisplayName = bot.effectiveTitle,
isUser = false,
avatarMeta = bot.botMeta()?.avatar,
text = event.token,
isStreaming = true,
)
}
state.copy(messages = updatedMessages)
}
}
}
}
is WsEvent.MessageComplete -> {
val sid = event.sessionId?.trim('\"', ' ')
if (sid != null) {
Log.d("GroupChatViewModel", "MessageComplete for session $sid: '${event.text.take(50)}'")
inFlightTurns[sid]?.complete(event.text)
}
}
else -> {}
}
}
}
}
private suspend fun fetchProfiles(): List<ProfileInfo> {
try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
val jsonElement =
when (rpcResult) {
is JsonElement -> rpcResult
null -> null
else -> rpcResult.toJsonElement()
}
if (jsonElement != null) {
val resp = json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
if (!resp.profiles.isNullOrEmpty()) {
return resp.profiles
}
}
} catch (_: Exception) {
// Fallback to REST API below
}
val result = safeApiCall { ApiClient.hermesApi.getProfiles() }
return (result as? NetworkResult.Success)?.data?.profiles.orEmpty()
}
fun loadGroup() {
viewModelScope.launch(ioDispatcher) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
val allProfiles = fetchProfiles()
if (allProfiles.isNotEmpty()) {
var groupMembers =
allProfiles.filter {
val botGroups = it.botMeta()?.groups.orEmpty()
botGroups.contains(groupName) || botGroups.any { g -> g.equals(groupName, ignoreCase = true) }
}
// Fallback: If group name was composed from bot names (e.g. "default, scoutbot")
if (groupMembers.isEmpty() && groupName.contains(",")) {
val targetNames = groupName.split(",").map { it.trim().lowercase() }
groupMembers = allProfiles.filter { targetNames.contains(it.name.lowercase()) }
}
// Final safety fallback: If 0 members found, use all non-hidden bots
if (groupMembers.isEmpty()) {
groupMembers = allProfiles.filter { !it.isHidden }
}
Log.d(
"GroupChatViewModel",
"Resolved ${groupMembers.size} members for group '$groupName': ${groupMembers.map { it.name }}",
)
// Hydrate previous group chat messages from cross-device sync snapshot
val defaultProfile = allProfiles.find { it.is_default == true || it.name == "default" }
val syncSnapshotElement = defaultProfile?.ui_meta?.get("hermes-bots-groups")
val syncSnapshot =
syncSnapshotElement?.let { el ->
try {
json.decodeFromJsonElement<GroupChatSyncSnapshot>(el)
} catch (e: Exception) {
Log.w("GroupChatViewModel", "Failed to decode sync snapshot: ${e.message}")
null
}
}
var initialMessages = _uiState.value.messages
if (initialMessages.isEmpty() && syncSnapshot?.rooms != null) {
val matchingRoom =
syncSnapshot.rooms[groupName]
?: syncSnapshot.rooms["name:$groupName"]
?: syncSnapshot.rooms["id:$groupName"]
?: syncSnapshot.rooms.values.find { it.name.equals(groupName, ignoreCase = true) }
val syncedLog = matchingRoom?.log.orEmpty()
if (syncedLog.isNotEmpty()) {
initialMessages =
syncedLog.map { entry ->
val isUser = entry.from?.kind != "member"
val senderName = entry.from?.name.orEmpty().ifBlank { if (isUser) "user" else "bot" }
val memberProfile = allProfiles.find { it.name.equals(senderName, ignoreCase = true) }
GroupChatMessage(
id = entry.id ?: UUID.randomUUID().toString(),
senderName = memberProfile?.name ?: senderName,
senderDisplayName =
if (isUser) "You" else (memberProfile?.effectiveTitle ?: senderName),
isUser = isUser,
avatarMeta = memberProfile?.botMeta()?.avatar,
text = entry.text.orEmpty(),
timestamp = entry.at ?: System.currentTimeMillis(),
thread = entry.thread,
)
}
Log.d("GroupChatViewModel", "Hydrated ${initialMessages.size} messages from sync snapshot")
}
}
_uiState.update {
it.copy(
members = groupMembers,
messages = initialMessages,
isLoading = false,
)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load group members",
)
}
}
}
}
private suspend fun ensureMemberSession(bot: ProfileInfo): MemberSession? {
val cached = memberSessions[bot.name]
if (cached != null) {
return cached
}
val title = "Group: $groupName"
try {
val createParams =
buildMap<String, Any> {
put("profile", bot.name)
put("title", title)
put("source", "desktop")
put("hidden", true)
}
val deferred = HermesWsClient.request(WsMethods.SESSION_CREATE, createParams)
val res = deferred.await()
val sessionInfo = extractSessionInfo(res)
if (sessionInfo != null) {
memberSessions[bot.name] = sessionInfo
return sessionInfo
}
} catch (e: Exception) {
Log.w("GroupChatViewModel", "session.create failed for ${bot.name}: ${e.message}")
}
return null
}
fun sendMessage(rawText: String) {
val text = rawText.trim()
Log.d("GroupChatViewModel", "sendMessage called with: '$text'")
if (text.isBlank()) return
val members = _uiState.value.members
if (members.isEmpty()) {
Log.w("GroupChatViewModel", "sendMessage: No members in group, ignoring")
return
}
val userMessage =
GroupChatMessage(
id = UUID.randomUUID().toString(),
senderName = "user",
senderDisplayName = "You",
isUser = true,
text = text,
)
_uiState.update {
it.copy(messages = it.messages + userMessage)
}
persistSyncSnapshot(_uiState.value.messages)
viewModelScope.launch(ioDispatcher) {
val responders = GroupChatMentions.resolveResponders(text, members)
Log.d("GroupChatViewModel", "Responders: ${responders.map { it.name }}")
for (bot in responders) {
_uiState.update { it.copy(activeSpeaker = bot.effectiveTitle) }
val prompt =
GroupChatMentions.buildTurnPrompt(
groupName = groupName,
viewer = bot,
peers = members.filter { it.name != bot.name },
recentLog = _uiState.value.messages,
)
val streamMsgId = UUID.randomUUID().toString()
var activeRuntimeId: String? = null
var activeStoredId: String? = null
try {
val sessionInfo = ensureMemberSession(bot)
if (sessionInfo == null) {
Log.w("GroupChatViewModel", "Could not obtain session for ${bot.name}")
continue
}
val runtimeId = sessionInfo.runtimeSessionId
val storedId = sessionInfo.storedSessionId
activeRuntimeId = runtimeId
activeStoredId = storedId
activeStreamMsgId[runtimeId] = streamMsgId
storedId?.let { activeStreamMsgId[it] = streamMsgId }
sessionToBot[runtimeId] = bot
storedId?.let { sessionToBot[it] = bot }
// Set ActiveSessionHolder so the active WebSocket context matches this turn
ActiveSessionHolder.set(runtimeId, runtimeId)
// Register in-flight listener
val turnDeferred = CompletableDeferred<String>()
inFlightTurns[runtimeId] = turnDeferred
storedId?.let { inFlightTurns[it] = turnDeferred }
// Submit prompt via sendMessage
HermesWsClient.sendMessage(
sessionId = runtimeId,
text = prompt,
)
// Await event or polling fallback
var replyText =
withTimeoutOrNull(45_000L) {
turnDeferred.await()
}
inFlightTurns.remove(runtimeId)
storedId?.let { inFlightTurns.remove(it) }
// Polling fallback if event dropped or session was reaped
if (replyText == null) {
val targetIds = listOfNotNull(storedId, runtimeId).distinct()
for (tid in targetIds) {
try {
val res =
safeApiCall {
ApiClient.hermesApi.getSessionMessages(
sessionId = tid,
limit = 10,
order = "latest",
)
}
if (res is NetworkResult.Success) {
val lastAssistant =
res.data.messages.reversed().firstOrNull { it.role == "assistant" }
val candidate = lastAssistant?.contentText?.trim()
if (!candidate.isNullOrBlank()) {
replyText = candidate
break
}
}
} catch (e: Exception) {
Log.w("GroupChatViewModel", "REST fallback failed for $tid: ${e.message}")
}
}
}
if (replyText != null && !isPass(replyText) && replyText.isNotBlank()) {
_uiState.update { state ->
val existing = state.messages.find { it.id == streamMsgId }
val finalMsg =
GroupChatMessage(
id = streamMsgId,
senderName = bot.name,
senderDisplayName = bot.effectiveTitle,
isUser = false,
avatarMeta = bot.botMeta()?.avatar,
text = replyText.trim(),
isStreaming = false,
)
val updatedList =
if (existing != null) {
state.messages.map { if (it.id == streamMsgId) finalMsg else it }
} else {
state.messages + finalMsg
}
state.copy(messages = updatedList)
}
persistSyncSnapshot(_uiState.value.messages)
} else {
_uiState.update { state ->
state.copy(messages = state.messages.filter { it.id != streamMsgId })
}
}
} catch (e: Exception) {
Log.w("GroupChatViewModel", "Turn failed for ${bot.name}: ${e.message}")
_uiState.update { state ->
state.copy(messages = state.messages.filter { it.id != streamMsgId })
}
} finally {
activeRuntimeId?.let {
activeStreamMsgId.remove(it)
sessionToBot.remove(it)
inFlightTurns.remove(it)
}
activeStoredId?.let {
activeStreamMsgId.remove(it)
sessionToBot.remove(it)
inFlightTurns.remove(it)
}
}
}
_uiState.update { it.copy(activeSpeaker = null) }
}
}
private fun persistSyncSnapshot(currentMessages: List<GroupChatMessage>) {
viewModelScope.launch(ioDispatcher) {
try {
val allProfiles = fetchProfiles()
val defaultProfile =
allProfiles.find { it.is_default == true || it.name == "default" } ?: return@launch
val existingElement = defaultProfile.ui_meta?.get("hermes-bots-groups")
val existingSnapshot =
existingElement?.let {
try {
json.decodeFromJsonElement<GroupChatSyncSnapshot>(it)
} catch (_: Exception) {
null
}
} ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap())
val logEntries =
currentMessages.takeLast(32).map { msg ->
GroupChatSyncLogEntry(
id = msg.id,
from =
GroupChatSyncFrom(
kind = if (msg.isUser) "user" else "member",
name = if (msg.isUser) "You" else msg.senderName,
),
text = msg.text,
at = msg.timestamp,
thread = msg.thread,
)
}
val roomKey = "name:$groupName"
val updatedRoom =
GroupChatRoomMeta(
name = groupName,
members = _uiState.value.members.map { JsonPrimitive(it.name) },
log = logEntries,
updatedAt = System.currentTimeMillis(),
)
val updatedRooms = (existingSnapshot.rooms.orEmpty() + (roomKey to updatedRoom))
val newSnapshot =
existingSnapshot.copy(
version = 3,
updatedAt = System.currentTimeMillis(),
rooms = updatedRooms,
)
val uiMetaPayload = mapOf("hermes-bots-groups" to snapshotToMap(newSnapshot))
HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
} catch (e: Exception) {
Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}")
}
}
}
private fun snapshotToMap(snapshot: GroupChatSyncSnapshot): Map<String, Any?> {
val roomsMap = mutableMapOf<String, Any?>()
snapshot.rooms?.forEach { (key, room) ->
val roomMap = mutableMapOf<String, Any?>()
room.name?.let { roomMap["name"] = it }
room.roomId?.let { roomMap["roomId"] = it }
room.picture?.let { roomMap["picture"] = it }
room.image?.let { roomMap["image"] = it }
room.updatedAt?.let { roomMap["updatedAt"] = it }
room.createdAt?.let { roomMap["createdAt"] = it }
room.revision?.let { roomMap["revision"] = it }
room.members?.let { members ->
roomMap["members"] =
members.mapNotNull {
when (it) {
is JsonPrimitive -> it.content
is JsonObject -> it["name"]?.jsonPrimitive?.content
else -> null
}
}
}
room.log?.let { log ->
roomMap["log"] =
log.map { entry ->
buildMap<String, Any?> {
entry.id?.let { put("id", it) }
entry.text?.let { put("text", it) }
entry.at?.let { put("at", it) }
entry.thread?.let { put("thread", it) }
entry.from?.let { f ->
put(
"from",
buildMap<String, Any?> {
f.kind?.let { put("kind", it) }
f.name?.let { put("name", it) }
f.source?.let { put("source", it) }
},
)
}
}
}
}
roomsMap[key] = roomMap
}
return buildMap {
put("version", snapshot.version ?: 3)
put("updatedAt", snapshot.updatedAt ?: System.currentTimeMillis())
put("rooms", roomsMap)
snapshot.deleted?.let { put("deleted", it) }
}
}
private fun isPass(text: String): Boolean {
val clean = text.trim().lowercase()
return clean == "(pass)" || clean == "pass"
}
private fun extractSessionInfo(response: Any?): MemberSession? {
if (response is Map<*, *>) {
val runtimeId =
response["session_id"]?.toString()?.trim('\"', ' ')
?: (response["result"] as? Map<*, *>)?.get("session_id")?.toString()?.trim('\"', ' ')
val storedId =
response["stored_session_id"]?.toString()?.trim('\"', ' ')
?: (response["result"] as? Map<*, *>)?.get("stored_session_id")?.toString()?.trim('\"', ' ')
if (!runtimeId.isNullOrBlank()) {
return MemberSession(runtimeSessionId = runtimeId, storedSessionId = storedId)
}
}
return null
}
}
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -160,7 +161,7 @@ fun HermesScaffold(
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
contentWindowInsets = WindowInsets(0, 0, 0, 0),
contentWindowInsets = WindowInsets.navigationBars,
snackbarHost = { snackbarHost() },
topBar = {
TopAppBar(
+8
View File
@@ -802,4 +802,12 @@
<item quantity="other">에이전트 %1$d개 실행 중</item>
</plurals>
<string name="task_progress_open_details">작업 세부 정보 열기</string>
<!-- Group Chat Room -->
<string name="group_chat_composer_hint">%1$s에게 메시지 보내기… (@bot으로 멘션)</string>
<string name="group_chat_empty_title">메시지 없음</string>
<string name="group_chat_empty_subtitle">봇을 멘션하거나 @all을 사용하여 협업을 시작하세요.</string>
<string name="group_chat_thinking">%1$s 생각 중…</string>
<string name="group_chat_typing">입력 중…</string>
<string name="group_chat_members_count">멤버 %1$d명</string>
<string name="group_chat_mention_all">@all</string>
</resources>
+8
View File
@@ -1101,4 +1101,12 @@
<!-- system action toast templates -->
<string name="system_action_started">%1$s 已开始</string>
<string name="system_action_failed">%1$s 失败:%2$s</string>
<!-- Group Chat Room -->
<string name="group_chat_composer_hint">发消息给 %1$s…(使用 @bot 提及)</string>
<string name="group_chat_empty_title">暂无消息</string>
<string name="group_chat_empty_subtitle">提及某个 Bot 或使用 @all 开始协同。</string>
<string name="group_chat_thinking">%1$s 正在思考…</string>
<string name="group_chat_typing">正在输入…</string>
<string name="group_chat_members_count">%1$d 个成员</string>
<string name="group_chat_mention_all">@all</string>
</resources>
+8
View File
@@ -1232,4 +1232,12 @@
<!-- system action toast templates -->
<string name="system_action_started">%1$s started</string>
<string name="system_action_failed">%1$s failed: %2$s</string>
<!-- Group Chat Room -->
<string name="group_chat_composer_hint">Message %1$s… (use @bot to mention)</string>
<string name="group_chat_empty_title">No Messages Yet</string>
<string name="group_chat_empty_subtitle">Mention a bot or use @all to kick off collaboration.</string>
<string name="group_chat_thinking">%1$s is thinking…</string>
<string name="group_chat_typing">Typing…</string>
<string name="group_chat_members_count">%1$d members</string>
<string name="group_chat_mention_all">@all</string>
</resources>
@@ -1,8 +1,9 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.encodeToJsonElement
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
@@ -11,120 +12,132 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class ProfileSerializationTest {
private val json = OkHttpProvider.json
private val json = Json { ignoreUnknownKeys = true }
@Test
fun testProfileInfoDeserializationWithBotMetadata() {
val jsonStr =
"""
{
"name": "researcher",
"path": "/home/user/.hermes/profiles/researcher",
"is_default": false,
"model": "anthropic/claude-3-7-sonnet",
"provider": "anthropic",
"skill_count": 5,
"description": "Base description",
"display_name": "Researcher Agent",
"has_avatar": true,
"canonical_session": {
"id": "sess-canon-123",
"resolved_id": "sess-canon-tip",
"root_title": "Bot Chat",
"title": "Bot Chat",
"preview": "Latest research report ready.",
"started_at": 1700000000,
"last_active": 1700005000,
"message_count": 42
},
"worker_session": {
"id": "worker-456",
"source": "kanban",
"title": "Background analysis",
"last_active": 1700006000
},
"last_session": {
"id": "last-789",
"title": "Ad-hoc task",
"preview": "Done!",
"started_at": 1700001000,
"last_active": 1700002000,
"message_count": 10
},
"ui_meta": {
"hermes-bots": {
"title": "Dr. Researcher",
"description": "Deep literature scout",
"avatar": {
"shape": "hexagon",
"color": "#4A90E2",
"icon": "science",
"image_url": "https://example.com/avatar.png"
},
"hidden": false,
"groups": ["science-team", "daily-sync"]
}
}
}
""".trimIndent()
fun testBotRosterMetaSerialization() {
val meta =
BotRosterMeta(
title = "Literature Scout",
description = "Scrapes arXiv and summarizes papers",
avatar =
BotAvatarMeta(
shape = "circle",
color = "#4A90E2",
icon = "science",
),
hidden = false,
groups = listOf("research", "ai-lab"),
group = "research",
created = 1718000000.0,
)
val profile = json.decodeFromString<ProfileInfo>(jsonStr)
assertEquals("researcher", profile.name)
assertEquals("Researcher Agent", profile.display_name)
assertTrue(profile.has_avatar == true)
val encoded = json.encodeToString(meta)
val decoded = json.decodeFromString<BotRosterMeta>(encoded)
// Canonical session
val canon = profile.canonical_session
assertNotNull(canon)
assertEquals("sess-canon-123", canon?.id)
assertEquals("sess-canon-tip", canon?.resolved_id)
assertEquals("Latest research report ready.", canon?.preview)
assertEquals(42, canon?.message_count)
assertEquals("Literature Scout", decoded.title)
assertEquals("Scrapes arXiv and summarizes papers", decoded.description)
assertEquals("circle", decoded.avatar?.shape)
assertEquals("#4A90E2", decoded.avatar?.color)
assertEquals("science", decoded.avatar?.icon)
assertEquals(false, decoded.hidden)
assertEquals(listOf("research", "ai-lab"), decoded.groups)
assertEquals("research", decoded.group)
assertEquals(1718000000.0, decoded.created)
}
// Worker session
val worker = profile.worker_session
assertNotNull(worker)
assertEquals("worker-456", worker?.id)
assertEquals("kanban", worker?.source)
@Test
fun testProfileInfoWithBotMetaExtraction() {
val botMeta =
BotRosterMeta(
title = "Custom Bot Title",
description = "Custom Bot Description",
avatar = BotAvatarMeta(shape = "hexagon", color = "#E91E63", icon = "robot"),
hidden = false,
)
// Bot metadata decoding
val botMeta = profile.botMeta(json)
assertNotNull(botMeta)
assertEquals("Dr. Researcher", botMeta?.title)
assertEquals("Deep literature scout", botMeta?.description)
assertEquals("hexagon", botMeta?.avatar?.shape)
assertEquals("#4A90E2", botMeta?.avatar?.color)
assertEquals(listOf("science-team", "daily-sync"), botMeta?.groups)
val profile =
ProfileInfo(
name = "custom_bot",
display_name = "Original Name",
description = "Original Description",
ui_meta = mapOf("hermes-bots" to json.encodeToJsonElement(botMeta)),
canonical_session =
CanonicalSessionInfo(
id = "sess-123",
last_active = 1720000000.0,
),
worker_session =
ProfileWorkerSummary(
id = "worker-456",
source = "kanban",
title = "Task #42",
last_active = 1720000100.0,
),
)
// Helpers
assertEquals("Dr. Researcher", profile.effectiveTitle)
assertEquals("Deep literature scout", profile.effectiveDescription)
val extractedMeta = profile.botMeta(json)
assertNotNull(extractedMeta)
assertEquals("Custom Bot Title", extractedMeta?.title)
assertEquals("Custom Bot Title", profile.effectiveTitle)
assertEquals("Custom Bot Description", profile.effectiveDescription)
assertFalse(profile.isHidden)
assertEquals("sess-123", profile.canonical_session?.id)
assertEquals("worker-456", profile.worker_session?.id)
}
@Test
fun testProfileInfoWithoutBotMeta_fallsBackToProfileFields() {
val profile =
ProfileInfo(
name = "regular_profile",
display_name = "My Profile",
description = "Just a profile",
)
assertNull(profile.botMeta(json))
assertEquals("My Profile", profile.effectiveTitle)
assertEquals("Just a profile", profile.effectiveDescription)
assertFalse(profile.isHidden)
}
@Test
fun testProfileInfoBackwardCompatibility() {
val legacyJson =
"""
{
"name": "default",
"is_default": true,
"model": "openai/gpt-4o",
"provider": "openai",
"description": "Default Assistant"
}
""".trimIndent()
fun testProfileInfoHidden_derivedFromBotMeta() {
val hiddenBot =
ProfileInfo(
name = "hidden_bot",
ui_meta =
mapOf(
"hermes-bots" to
json.encodeToJsonElement(
BotRosterMeta(title = "Hidden", hidden = true),
),
),
)
val visibleBot =
ProfileInfo(
name = "visible_bot",
ui_meta =
mapOf(
"hermes-bots" to
json.encodeToJsonElement(
BotRosterMeta(title = "Visible", hidden = false),
),
),
)
assertTrue(hiddenBot.isHidden)
assertFalse(visibleBot.isHidden)
}
@Test
fun testProfileInfoDefault_fallbackNames() {
val profile = ProfileInfo(name = "default")
val profile = json.decodeFromString<ProfileInfo>(legacyJson)
assertEquals("default", profile.name)
assertTrue(profile.is_default == true)
assertNull(profile.canonical_session)
assertNull(profile.worker_session)
assertNull(profile.last_session)
assertNull(profile.ui_meta)
assertNull(profile.botMeta(json))
assertEquals("default", profile.effectiveTitle)
assertEquals("Default Assistant", profile.effectiveDescription)
assertEquals("", profile.effectiveDescription)
assertFalse(profile.isHidden)
}
@@ -140,8 +153,17 @@ class ProfileSerializationTest {
GroupChatRoomMeta(
id = "room-1",
name = "War Room",
members = listOf("default", "researcher"),
members = listOf(JsonPrimitive("default"), JsonPrimitive("researcher")),
picture = null,
log =
listOf(
GroupChatSyncLogEntry(
id = "msg-1",
from = GroupChatSyncFrom(kind = "user", name = "You"),
text = "Let's review the code",
at = 1724000000L,
),
),
updatedAt = 1724000000L,
createdAt = 1723000000L,
),
@@ -153,8 +175,11 @@ class ProfileSerializationTest {
val decoded = json.decodeFromString<GroupChatSyncSnapshot>(encoded)
assertEquals(3, decoded.version)
assertEquals(1, decoded.rooms?.size)
assertEquals("War Room", decoded.rooms?.get("room-1")?.name)
assertEquals(listOf("default", "researcher"), decoded.rooms?.get("room-1")?.members)
val room = decoded.rooms?.get("room-1")
assertEquals("War Room", room?.name)
assertEquals(listOf("default", "researcher"), room?.memberNames)
assertEquals(1, room?.log?.size)
assertEquals("Let's review the code", room?.log?.first()?.text)
assertEquals(1723500000L, decoded.deleted?.get("old-room"))
}
}
@@ -0,0 +1,71 @@
package com.m57.hermescontrol.ui.bots.group
import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.BotRosterMeta
import com.m57.hermescontrol.data.model.ProfileInfo
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class GroupChatMentionsTest {
private val json = Json { ignoreUnknownKeys = true }
private val botA =
ProfileInfo(
name = "scoutbot",
ui_meta =
mapOf(
"hermes-bots" to
json.encodeToJsonElement(
BotRosterMeta(title = "Scout Bot", avatar = BotAvatarMeta()),
),
),
)
private val botB =
ProfileInfo(
name = "coder",
ui_meta =
mapOf(
"hermes-bots" to
json.encodeToJsonElement(
BotRosterMeta(title = "Dev Bot", avatar = BotAvatarMeta()),
),
),
)
private val members = listOf(botA, botB)
@Test
fun parseMentions_detectsDirectBotHandle() {
val result = GroupChatMentions.parseMentions("Hey @scoutbot can you check this?", members)
assertFalse(result.isEveryone)
assertTrue(result.mentionedBots.contains("scoutbot"))
assertFalse(result.mentionedBots.contains("coder"))
}
@Test
fun parseMentions_detectsEveryoneAndAll() {
val resEveryone = GroupChatMentions.parseMentions("Hello @everyone!", members)
assertTrue(resEveryone.isEveryone)
val resAll = GroupChatMentions.parseMentions("@all status update please", members)
assertTrue(resAll.isEveryone)
}
@Test
fun resolveResponders_defaultsToAllWhenNoMention() {
val responders = GroupChatMentions.resolveResponders("General discussion message", members)
assertEquals(2, responders.size)
}
@Test
fun resolveResponders_filtersToMentionedOnly() {
val responders = GroupChatMentions.resolveResponders("@coder build the apk", members)
assertEquals(1, responders.size)
assertEquals("coder", responders.first().name)
}
}
@@ -0,0 +1,266 @@
package com.m57.hermescontrol.ui.bots.group
import android.util.Log
import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.BotRosterMeta
import com.m57.hermescontrol.data.model.GroupChatRoomMeta
import com.m57.hermescontrol.data.model.GroupChatSyncFrom
import com.m57.hermescontrol.data.model.GroupChatSyncLogEntry
import com.m57.hermescontrol.data.model.GroupChatSyncSnapshot
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.encodeToJsonElement
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class GroupChatViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService
private val eventsFlow = MutableSharedFlow<WsEvent>()
private val json = Json { ignoreUnknownKeys = true }
private val botA =
ProfileInfo(
name = "scout",
ui_meta =
mapOf(
"hermes-bots" to
json.encodeToJsonElement(
BotRosterMeta(
title = "Scout Bot",
groups = listOf("Dev Team"),
avatar = BotAvatarMeta(icon = "science"),
),
),
),
)
private val botB =
ProfileInfo(
name = "coder",
ui_meta =
mapOf(
"hermes-bots" to
json.encodeToJsonElement(
BotRosterMeta(
title = "Dev Bot",
groups = listOf("Dev Team"),
avatar = BotAvatarMeta(icon = "code"),
),
),
),
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any(), any<String>()) } returns 0
every { Log.e(any(), any()) } returns 0
mockkObject(ApiClient)
mockApi = mockk(relaxed = true)
every { ApiClient.hermesApi } returns mockApi
coEvery { mockApi.getProfiles() } returns
Response.success(
ProfilesResponse(
profiles = listOf(botA, botB),
),
)
mockkObject(HermesWsClient)
every { HermesWsClient.events } returns eventsFlow
every { HermesWsClient.send(any(), any(), any()) } returns "req-1"
every { HermesWsClient.sendMessage(any(), any(), any()) } returns "req-msg"
val createDeferred = CompletableDeferred<Any?>()
createDeferred.complete(mapOf("session_id" to "session-scout-1"))
every { HermesWsClient.request(any(), any(), any()) } returns createDeferred
}
@After
fun tearDown() {
unmockkObject(ApiClient)
unmockkObject(HermesWsClient)
unmockkStatic(Log::class)
Dispatchers.resetMain()
}
@Test
fun testLoadGroup_resolvesMembers() =
runTest(testDispatcher) {
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isLoading)
assertNull(state.errorMessage)
assertEquals(2, state.members.size)
assertEquals("scout", state.members[0].name)
assertEquals("coder", state.members[1].name)
}
@Test
fun testLoadGroup_hydratesFromSyncSnapshot() =
runTest(testDispatcher) {
val syncSnapshot =
GroupChatSyncSnapshot(
version = 3,
updatedAt = 1724000000L,
rooms =
mapOf(
"name:Dev Team" to
GroupChatRoomMeta(
name = "Dev Team",
members = listOf(JsonPrimitive("scout"), JsonPrimitive("coder")),
log =
listOf(
GroupChatSyncLogEntry(
id = "msg-prev-1",
from = GroupChatSyncFrom(kind = "user", name = "You"),
text = "Initial design spec",
at = 1724000000L,
),
GroupChatSyncLogEntry(
id = "msg-prev-2",
from = GroupChatSyncFrom(kind = "member", name = "scout"),
text = "Looks solid, ready to build.",
at = 1724000010L,
),
),
),
),
)
val defaultProfileWithSnapshot =
ProfileInfo(
name = "default",
is_default = true,
ui_meta = mapOf("hermes-bots-groups" to json.encodeToJsonElement(syncSnapshot)),
)
coEvery { mockApi.getProfiles() } returns
Response.success(
ProfilesResponse(
profiles = listOf(defaultProfileWithSnapshot, botA, botB),
),
)
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(2, state.messages.size)
assertEquals("msg-prev-1", state.messages[0].id)
assertTrue(state.messages[0].isUser)
assertEquals("Initial design spec", state.messages[0].text)
assertEquals("msg-prev-2", state.messages[1].id)
assertFalse(state.messages[1].isUser)
assertEquals("scout", state.messages[1].senderName)
assertEquals("Looks solid, ready to build.", state.messages[1].text)
}
@Test
fun testSendMessage_streamsTokensAndCompletes() =
runTest(testDispatcher) {
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
testScheduler.runCurrent()
// Send message targeting only @scout
viewModel.sendMessage("@scout what's the status?")
testScheduler.runCurrent()
// State should contain user message
val userMsg = viewModel.uiState.value.messages.first()
assertTrue(userMsg.isUser)
assertEquals("@scout what's the status?", userMsg.text)
// Stream tokens
eventsFlow.emit(WsEvent.MessageToken(token = "Building ", sessionId = "session-scout-1"))
testScheduler.runCurrent()
val streamingMsg = viewModel.uiState.value.messages.last()
assertFalse(streamingMsg.isUser)
assertEquals("scout", streamingMsg.senderName)
assertEquals("Building ", streamingMsg.text)
assertTrue(streamingMsg.isStreaming)
eventsFlow.emit(WsEvent.MessageToken(token = "the APK...", sessionId = "session-scout-1"))
testScheduler.runCurrent()
val updatedStreaming = viewModel.uiState.value.messages.last()
assertEquals("Building the APK...", updatedStreaming.text)
// Complete turn
eventsFlow.emit(
WsEvent.MessageComplete(
text = "Building the APK now! Everything looks green.",
sessionId = "session-scout-1",
),
)
testScheduler.runCurrent()
val finalState = viewModel.uiState.value
assertEquals(2, finalState.messages.size)
val botMsg = finalState.messages[1]
assertEquals("Building the APK now! Everything looks green.", botMsg.text)
assertFalse(botMsg.isStreaming)
assertNull(finalState.activeSpeaker)
}
@Test
fun testSendMessage_filtersOutPass() =
runTest(testDispatcher) {
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
testScheduler.runCurrent()
viewModel.sendMessage("@scout any updates?")
testScheduler.runCurrent()
eventsFlow.emit(
WsEvent.MessageComplete(
text = "(pass)",
sessionId = "session-scout-1",
),
)
testScheduler.runCurrent()
val finalState = viewModel.uiState.value
// Only the user message should remain; (pass) is filtered out
assertEquals(1, finalState.messages.size)
assertTrue(finalState.messages.first().isUser)
}
}