diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 5f8d5530..cb20c51c 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -155,6 +155,14 @@ private fun appEntryProvider( onBack = { NavigationController.goBack() }, ) } + + // ── Multi-Agent Group Chat Room ───────────────────────────────────── + entry { key -> + com.m57.hermescontrol.ui.bots.group.GroupChatScreen( + groupName = key.groupName, + onBack = { NavigationController.goBack() }, + ) + } } @Composable diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 2bacb4a1..1934804c 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt index c2fcdff2..1e9e5a93 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt @@ -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? = null, + val members: List? = null, val picture: String? = null, + val image: String? = null, + val log: List? = null, + val revision: Long? = null, val updatedAt: Long? = null, val createdAt: Long? = null, +) { + val memberNames: List + 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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt index 8a18a0ce..279a479f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt @@ -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 = { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateBotDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateBotDialog.kt index 0f70cf58..ec411ed7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateBotDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateBotDialog.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/EditBotBottomSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/EditBotBottomSheet.kt index bad687cf..c55f488a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/EditBotBottomSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/EditBotBottomSheet.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt new file mode 100644 index 00000000..477603b0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt @@ -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, + ) + + /** + * Parse `@botname`, `@all`, `@everyone` from text against the member roster. + */ + fun parseMentions( + text: String, + members: List, + ): MentionResult { + val lowerText = text.lowercase() + val isEveryone = lowerText.contains("@all") || lowerText.contains("@everyone") + + val mentioned = mutableSetOf() + 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, + ): List { + 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, + recentLog: List, + ): 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() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt new file mode 100644 index 00000000..194a0e21 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -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, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt new file mode 100644 index 00000000..1a74eaa2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -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 = emptyList(), + val messages: List = 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 = _uiState.asStateFlow() + + // Map of bot.name -> active session info in this group + private val memberSessions = mutableMapOf() + + // In-flight turn completion deferreds keyed by session_id + private val inFlightTurns = mutableMapOf>() + + // Map of session_id -> active streaming message ID + private val activeStreamMsgId = mutableMapOf() + + // Map of session_id -> bot profile + private val sessionToBot = mutableMapOf() + + 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 { + 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(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(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 { + 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() + 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) { + 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(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 { + val roomsMap = mutableMapOf() + snapshot.rooms?.forEach { (key, room) -> + val roomMap = mutableMapOf() + 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 { + 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 { + 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 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt index 32f285e4..2fca0126 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt @@ -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( diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b7afce43..b43199b2 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -802,4 +802,12 @@ 에이전트 %1$d개 실행 중 작업 세부 정보 열기 + + %1$s에게 메시지 보내기… (@bot으로 멘션) + 메시지 없음 + 봇을 멘션하거나 @all을 사용하여 협업을 시작하세요. + %1$s 생각 중… + 입력 중… + 멤버 %1$d명 + @all \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index cee14cbc..7f4f646a 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1101,4 +1101,12 @@ %1$s 已开始 %1$s 失败:%2$s + + 发消息给 %1$s…(使用 @bot 提及) + 暂无消息 + 提及某个 Bot 或使用 @all 开始协同。 + %1$s 正在思考… + 正在输入… + %1$d 个成员 + @all diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4466c30..bc801afe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1232,4 +1232,12 @@ %1$s started %1$s failed: %2$s + + Message %1$s… (use @bot to mention) + No Messages Yet + Mention a bot or use @all to kick off collaboration. + %1$s is thinking… + Typing… + %1$d members + @all diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ProfileSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ProfileSerializationTest.kt index 267590cb..7a4b5497 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/ProfileSerializationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ProfileSerializationTest.kt @@ -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(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(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(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(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")) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt new file mode 100644 index 00000000..4171f758 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt new file mode 100644 index 00000000..e91babd7 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt @@ -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() + 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()) } 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() + 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) + } +}