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 index 17de3dc2..b6eb32da 100644 --- 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 @@ -2,6 +2,17 @@ package com.m57.hermescontrol.ui.bots.group import com.m57.hermescontrol.data.model.BotAvatarMeta import com.m57.hermescontrol.data.model.ProfileInfo +import java.util.UUID + +/** + * Lightweight tool execution indicator for group chat turns. + */ +data class GroupChatToolCall( + val id: String = UUID.randomUUID().toString(), + val name: String, + val summary: String? = null, + val isRunning: Boolean = false, +) /** * Single message entry in a multi-agent group room. @@ -17,6 +28,7 @@ data class GroupChatMessage( val isStreaming: Boolean = false, val thread: String? = null, val isSystem: Boolean = false, + val toolCalls: List = emptyList(), ) /** 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 index 398db70a..fc9cb6d6 100644 --- 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 @@ -24,7 +24,11 @@ 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.material.icons.filled.Build +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Terminal import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -56,6 +60,7 @@ 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.text.style.TextOverflow import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -542,8 +547,19 @@ private fun GroupMessageCard( fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) + if (message.toolCalls.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(bottom = if (message.text.isNotBlank()) 4.dp else 0.dp), + ) { + message.toolCalls.forEach { tool -> + GroupChatToolChip(tool = tool) + } + } + } Spacer(modifier = Modifier.height(4.dp)) - if (message.isStreaming && message.text.isBlank()) { + if (message.isStreaming && message.text.isBlank() && message.toolCalls.none { it.isRunning }) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 4.dp), @@ -571,3 +587,56 @@ private fun GroupMessageCard( } } } + +@Composable +private fun GroupChatToolChip( + tool: GroupChatToolCall, + modifier: Modifier = Modifier, +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + modifier = modifier, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val icon = + when (tool.name.lowercase()) { + "terminal" -> Icons.Filled.Terminal + "web_search", "search_files" -> Icons.Filled.Search + "read_file", "write_file", "patch" -> Icons.Filled.Description + else -> Icons.Filled.Build + } + Icon( + imageVector = icon, + contentDescription = tool.name, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.primary, + ) + val label = + if (!tool.summary.isNullOrBlank()) { + "${tool.name}: ${tool.summary}" + } else { + tool.name + } + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (tool.isRunning) { + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} 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 index d083f3bb..fa4d121f 100644 --- 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 @@ -107,6 +107,19 @@ class GroupChatViewModel( loadGroup() } + private fun extractToolSummary(data: Map?): String? { + if (data == null) return null + val cmd = (data["command"] as? String)?.trim() + if (!cmd.isNullOrBlank()) return cmd.take(35) + val query = (data["query"] as? String)?.trim() + if (!query.isNullOrBlank()) return query.take(35) + val path = (data["path"] as? String)?.trim() + if (!path.isNullOrBlank()) return path.take(35) + val pattern = (data["pattern"] as? String)?.trim() + if (!pattern.isNullOrBlank()) return pattern.take(35) + return null + } + private fun observeWsEvents() { viewModelScope.launch(ioDispatcher) { HermesWsClient.events.collect { event -> @@ -146,6 +159,93 @@ class GroupChatViewModel( } } + is WsEvent.ToolStart -> { + val sid = event.sessionId?.trim('\"', ' ') + if (sid != null) { + val streamId = activeStreamMsgId[sid] + val bot = sessionToBot[sid] + val toolName = event.name ?: "tool" + val toolId = + (event.data?.get("tool_id") as? String)?.ifBlank { null } + ?: UUID.randomUUID().toString() + val summary = extractToolSummary(event.data) + val toolCall = + GroupChatToolCall( + id = toolId, + name = toolName, + summary = summary, + isRunning = true, + ) + if (streamId != null && bot != null) { + _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(toolCalls = msg.toolCalls + toolCall) + } else { + msg + } + } + } else { + state.messages + + GroupChatMessage( + id = streamId, + senderName = bot.name, + senderDisplayName = bot.effectiveTitle, + isUser = false, + avatarMeta = bot.botMeta()?.avatar, + text = "", + isStreaming = true, + toolCalls = listOf(toolCall), + ) + } + state.copy(messages = updatedMessages) + } + } + } + } + + is WsEvent.ToolComplete -> { + val sid = event.sessionId?.trim('\"', ' ') + if (sid != null) { + val streamId = activeStreamMsgId[sid] + val toolId = event.data?.get("tool_id") as? String + val toolName = event.name + if (streamId != null) { + _uiState.update { state -> + val existing = state.messages.find { it.id == streamId } + if (existing != null) { + val updatedTools = + existing.toolCalls.map { tool -> + if ((toolId != null && tool.id == toolId) || + (toolId == null && tool.name == toolName && tool.isRunning) || + (toolId == null && toolName == null && tool.isRunning) + ) { + tool.copy(isRunning = false) + } else { + tool + } + } + state.copy( + messages = + state.messages.map { msg -> + if (msg.id == streamId) { + msg.copy(toolCalls = updatedTools) + } else { + msg + } + }, + ) + } else { + state + } + } + } + } + } + is WsEvent.MessageComplete -> { val sid = event.sessionId?.trim('\"', ' ') if (sid != null) { @@ -617,18 +717,21 @@ class GroupChatViewModel( } if (replyText != null && !isPass(replyText) && replyText.isNotBlank()) { - val finalMsg = - GroupChatMessage( - id = streamMsgId, - senderName = bot.name, - senderDisplayName = bot.effectiveTitle, - isUser = false, - avatarMeta = bot.botMeta()?.avatar, - text = replyText.trim(), - isStreaming = false, - ) _uiState.update { state -> val existing = state.messages.find { it.id == streamMsgId } + val finalTools = + existing?.toolCalls?.map { it.copy(isRunning = false) } ?: emptyList() + val finalMsg = + GroupChatMessage( + id = streamMsgId, + senderName = bot.name, + senderDisplayName = bot.effectiveTitle, + isUser = false, + avatarMeta = bot.botMeta()?.avatar, + text = replyText.trim(), + isStreaming = false, + toolCalls = finalTools, + ) val updatedList = if (existing != null) { state.messages.map { if (it.id == streamMsgId) finalMsg else it } 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 index ff7730ae..c223f648 100644 --- 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 @@ -432,4 +432,58 @@ class GroupChatViewModelTest { assertTrue(prompt.contains("@scout (Scout Bot)")) assertTrue(prompt.contains("@coder (Dev Bot)")) } + + @Test + fun testToolEvents_rendersAndCompletesToolCall() = + runTest(testDispatcher) { + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + testScheduler.runCurrent() + + viewModel.sendMessage("@scout check time") + testScheduler.runCurrent() + + // Emit ToolStart + eventsFlow.emit( + WsEvent.ToolStart( + name = "terminal", + data = mapOf("command" to "date", "tool_id" to "tool-123"), + sessionId = "session-scout-1", + ), + ) + testScheduler.runCurrent() + + val msgWithTool = viewModel.uiState.value.messages.last() + assertEquals(1, msgWithTool.toolCalls.size) + val toolCall = msgWithTool.toolCalls.first() + assertEquals("terminal", toolCall.name) + assertEquals("date", toolCall.summary) + assertTrue(toolCall.isRunning) + + // Emit ToolComplete + eventsFlow.emit( + WsEvent.ToolComplete( + name = "terminal", + data = mapOf("tool_id" to "tool-123"), + sessionId = "session-scout-1", + ), + ) + testScheduler.runCurrent() + + val msgAfterToolDone = viewModel.uiState.value.messages.last() + assertFalse(msgAfterToolDone.toolCalls.first().isRunning) + + // Complete message + eventsFlow.emit( + WsEvent.MessageComplete( + text = "Current time is Sat Aug 29", + sessionId = "session-scout-1", + ), + ) + testScheduler.runCurrent() + + val finalMsg = viewModel.uiState.value.messages.last() + assertEquals("Current time is Sat Aug 29", finalMsg.text) + assertEquals(1, finalMsg.toolCalls.size) + assertFalse(finalMsg.toolCalls.first().isRunning) + } }