From 0d09f43469706e217c62d596f5248ad306e470e8 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:44:08 +0300 Subject: [PATCH] feat(bots): reactive multi-agent group chat orchestration with CAS leasing (#1007) Co-authored-by: M57 --- .../m57/hermescontrol/data/model/Profile.kt | 22 + .../data/model/SessionMessagesResponse.kt | 16 + .../ui/bots/group/GroupChatModels.kt | 95 ++- .../ui/bots/group/GroupChatScreen.kt | 218 +++++- .../ui/bots/group/GroupChatViewModel.kt | 650 ++++++++++++++---- app/src/main/res/values-ko/strings.xml | 12 + app/src/main/res/values-zh/strings.xml | 12 + app/src/main/res/values/strings.xml | 12 + .../ui/bots/group/GroupChatViewModelTest.kt | 169 +++++ 9 files changed, 1059 insertions(+), 147 deletions(-) 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 711ed2b1..f06756a7 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 @@ -157,6 +157,17 @@ data class GroupChatSyncSnapshot( room.revision?.let { roomMap["revision"] = it } room.syncRevision?.let { roomMap["syncRevision"] = it } room.tombstone?.let { roomMap["tombstone"] = it } + room.maxBotMessages?.let { roomMap["maxBotMessages"] = it } + room.maxContinuationPasses?.let { roomMap["maxContinuationPasses"] = it } + room.systemPrompt?.let { roomMap["systemPrompt"] = it } + room.lease?.let { lease -> + roomMap["lease"] = + buildMap { + lease.driverId?.let { put("driverId", it) } + lease.epoch?.let { put("epoch", it) } + lease.expiresAt?.let { put("expiresAt", it) } + } + } room.members?.let { members -> roomMap["members"] = members.mapNotNull { @@ -200,6 +211,13 @@ data class GroupChatSyncSnapshot( } } +@Serializable +data class GroupChatRoomLease( + val driverId: String? = null, + val epoch: String? = null, + val expiresAt: Long? = null, +) + @Serializable data class GroupChatRoomMeta( val id: String? = null, @@ -214,6 +232,10 @@ data class GroupChatRoomMeta( val updatedAt: Long? = null, val createdAt: Long? = null, val tombstone: Boolean? = null, + val lease: GroupChatRoomLease? = null, + val maxBotMessages: Int? = null, + val maxContinuationPasses: Int? = null, + val systemPrompt: String? = null, ) { val memberNames: List get() = diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt index d256d743..7f2ca663 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt @@ -3,6 +3,8 @@ package com.m57.hermescontrol.data.model import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull @Serializable data class SessionMessagesResponse( @@ -52,6 +54,20 @@ data class SessionMessage( val timestampText: String? get() = (timestamp as? JsonPrimitive)?.content + val timestampEpochMs: Long? + get() { + val prim = timestamp as? JsonPrimitive ?: return null + val asLong = prim.longOrNull + if (asLong != null) { + return if (asLong < 10_000_000_000L) asLong * 1000L else asLong + } + val asDouble = prim.doubleOrNull + if (asDouble != null) { + return (asDouble * 1000L).toLong() + } + return null + } + val contentText: String get() = when (content) { 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 477603b0..17de3dc2 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 @@ -16,12 +16,15 @@ data class GroupChatMessage( val timestamp: Long = System.currentTimeMillis(), val isStreaming: Boolean = false, val thread: String? = null, + val isSystem: Boolean = false, ) /** * Pure helper for mention extraction and responder resolution matching Desktop parity. */ object GroupChatMentions { + private val MENTION_REGEX = Regex("""@([a-zA-Z0-9._-]+)""") + data class MentionResult( val isEveryone: Boolean, val mentionedBots: Set, @@ -33,16 +36,42 @@ object GroupChatMentions { fun parseMentions( text: String, members: List, + excludedSpeaker: String? = null, ): MentionResult { - val lowerText = text.lowercase() - val isEveryone = lowerText.contains("@all") || lowerText.contains("@everyone") - + var isEveryone = false val mentioned = mutableSetOf() + + val handles = mutableMapOf() 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) + val name = member.name.lowercase() + handles[name] = member.name + handles[name.replace(Regex("""[\s_-]+"""), "")] = member.name + + val title = member.effectiveTitle.lowercase().trim() + if (title.isNotEmpty()) { + handles[title.replace(" ", "")] = member.name + val firstWord = title.split(Regex("""\s+""")).firstOrNull().orEmpty() + if (firstWord.isNotEmpty()) { + handles[firstWord] = member.name + } + } + } + + for (match in MENTION_REGEX.findAll(text)) { + val rawHandle = match.groupValues[1].lowercase() + if (rawHandle == "all" || rawHandle == "everyone") { + isEveryone = true + continue + } + if (rawHandle == "user" || rawHandle == "you") { + continue + } + + val resolved = handles[rawHandle] ?: handles[rawHandle.replace(Regex("""[._-]+"""), "")] + if (resolved != null) { + if (excludedSpeaker == null || !resolved.equals(excludedSpeaker, ignoreCase = true)) { + mentioned.add(resolved) + } } } @@ -58,10 +87,15 @@ object GroupChatMentions { fun resolveResponders( text: String, members: List, + excludedSpeaker: String? = null, ): List { - val parsed = parseMentions(text, members) + val parsed = parseMentions(text, members, excludedSpeaker) return if (parsed.isEveryone || parsed.mentionedBots.isEmpty()) { - members + if (excludedSpeaker != null) { + members.filter { !it.name.equals(excludedSpeaker, ignoreCase = true) } + } else { + members + } } else { members.filter { parsed.mentionedBots.contains(it.name) } } @@ -75,11 +109,25 @@ object GroupChatMentions { viewer: ProfileInfo, peers: List, recentLog: List, + historyLimit: Int = 10, + customRoomPrompt: String? = null, ): String { - val viewerHandle = viewer.name - val peerNames = peers.joinToString(", ") { "@${it.name}" } + val viewerLabel = + if (viewer.effectiveTitle.isNotBlank() && !viewer.effectiveTitle.equals(viewer.name, ignoreCase = true)) { + "@${viewer.name} (${viewer.effectiveTitle})" + } else { + "@${viewer.name}" + } + val peerNames = + peers.joinToString(", ") { + if (it.effectiveTitle.isNotBlank() && !it.effectiveTitle.equals(it.name, ignoreCase = true)) { + "@${it.name} (${it.effectiveTitle})" + } else { + "@${it.name}" + } + } val lines = - recentLog.takeLast(10).joinToString("\n") { msg -> + recentLog.filter { !it.isSystem }.takeLast(historyLimit).joinToString("\n") { msg -> val speaker = if (msg.isUser) "User" else "@${msg.senderName}" " $speaker: ${msg.text}" } @@ -90,20 +138,33 @@ object GroupChatMentions { val actionGuidance = if (isDirectOrAll) { - "- You were explicitly addressed (via @$viewerHandle or @all). Do NOT pass — reply conversationally with your greeting, insight, answer, or status." + "- You were explicitly addressed (via @${viewer.name} 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. + val customInstructions = + if (!customRoomPrompt.isNullOrBlank()) { + "\nRoom Instructions:\n${customRoomPrompt.trim()}\n" + } else { + "" + } + val defaultSentenceRule = + if (customRoomPrompt.isNullOrBlank()) { + "- Reply with ONE conversational message (1-3 sentences).\n" + } else { + "" + } + + return """ +[Group chat: "$groupName"] You are $viewerLabel, one participant in a group chat with $peerNames and the user. +$customInstructions Recent messages in the room: $lines Rules for this room: -- Reply with ONE conversational message (1-3 sentences). -$actionGuidance +$defaultSentenceRule$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 index d39b926d..398db70a 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,13 +24,19 @@ 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.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button 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.OutlinedTextField +import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -60,6 +66,7 @@ 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 +import kotlin.math.roundToInt @Composable fun GroupChatScreen( @@ -74,6 +81,7 @@ fun GroupChatScreen( mutableStateOf(TextFieldValue("")) } var isFocused by remember { mutableStateOf(false) } + var showSettingsDialog by remember { mutableStateOf(false) } val handleSend = { val text = inputFieldValue.text @@ -111,6 +119,17 @@ fun GroupChatScreen( } }, navigationIcon = NavIcon.Back(onBack), + actions = { + IconButton( + onClick = { showSettingsDialog = true }, + modifier = Modifier.testTag("group_chat_settings_button"), + ) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = stringResource(R.string.group_chat_action_settings), + ) + } + }, modifier = modifier, ) { Column( @@ -276,6 +295,185 @@ fun GroupChatScreen( } } } + + if (showSettingsDialog) { + GroupChatSettingsDialog( + currentMaxMessages = state.maxBotMessages, + currentMaxPasses = state.maxContinuationPasses, + currentSystemPrompt = state.systemPrompt, + onDismiss = { showSettingsDialog = false }, + onSave = { maxMsgs, maxPasses, prompt -> + viewModel.updateGroupLimits(maxMsgs, maxPasses, prompt) + showSettingsDialog = false + }, + ) + } +} + +@Composable +private fun GroupChatSettingsDialog( + currentMaxMessages: Int, + currentMaxPasses: Int, + currentSystemPrompt: String?, + onDismiss: () -> Unit, + onSave: (Int, Int, String?) -> Unit, +) { + var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) } + var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.toFloat()) } + var systemPrompt by remember(currentSystemPrompt) { mutableStateOf(currentSystemPrompt.orEmpty()) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.group_chat_settings_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Max Bot Messages Slider + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.group_chat_settings_max_messages), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "${maxMessages.roundToInt()}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + Text( + text = + stringResource( + R.string.group_chat_settings_max_messages_desc, + maxMessages.roundToInt(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = maxMessages, + onValueChange = { maxMessages = it }, + valueRange = 1f..20f, + steps = 18, + modifier = Modifier.testTag("max_messages_slider"), + ) + } + + // Max Continuation Handoffs Slider + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.group_chat_settings_max_handoffs), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "${maxPasses.roundToInt()}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + Text( + text = + stringResource( + R.string.group_chat_settings_max_handoffs_desc, + maxPasses.roundToInt(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = maxPasses, + onValueChange = { maxPasses = it }, + valueRange = 0f..6f, + steps = 5, + modifier = Modifier.testTag("max_handoffs_slider"), + ) + } + + // Room Instructions / System Prompt + Column { + Text( + text = stringResource(R.string.group_chat_settings_system_prompt), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(R.string.group_chat_settings_system_prompt_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(6.dp)) + OutlinedTextField( + value = systemPrompt, + onValueChange = { systemPrompt = it }, + placeholder = { + Text( + text = stringResource(R.string.group_chat_settings_system_prompt_hint), + style = MaterialTheme.typography.bodySmall, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 80.dp, max = 160.dp) + .testTag("room_system_prompt_input"), + textStyle = MaterialTheme.typography.bodyMedium, + maxLines = 4, + ) + } + } + }, + confirmButton = { + Button( + onClick = { + onSave( + maxMessages.roundToInt(), + maxPasses.roundToInt(), + systemPrompt.trim().ifBlank { null }, + ) + }, + modifier = Modifier.testTag("save_settings_button"), + ) { + Text(stringResource(R.string.group_chat_settings_save)) + } + }, + dismissButton = { + Row { + TextButton( + onClick = { + maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat() + maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat() + systemPrompt = "" + }, + ) { + Text(stringResource(R.string.group_chat_settings_reset_default)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_cancel)) + } + } + }, + ) } @Composable @@ -283,7 +481,25 @@ private fun GroupMessageCard( message: GroupChatMessage, modifier: Modifier = Modifier, ) { - if (message.isUser) { + if (message.isSystem) { + Box( + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Text( + text = message.text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + } + } else if (message.isUser) { Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, 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 ed73fb4d..d083f3bb 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 @@ -3,6 +3,7 @@ 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.GroupChatRoomLease import com.m57.hermescontrol.data.model.GroupChatRoomMeta import com.m57.hermescontrol.data.model.GroupChatSyncFrom import com.m57.hermescontrol.data.model.GroupChatSyncLogEntry @@ -39,6 +40,9 @@ data class GroupChatUiState( val activeSpeaker: String? = null, val isLoading: Boolean = true, val errorMessage: String? = null, + val maxBotMessages: Int = DEFAULT_MAX_BOT_MESSAGES, + val maxContinuationPasses: Int = DEFAULT_MAX_CONTINUATION_PASSES, + val systemPrompt: String? = null, ) data class MemberSession( @@ -46,6 +50,13 @@ data class MemberSession( val storedSessionId: String? = null, ) +const val DEFAULT_MAX_BOT_MESSAGES = 6 +const val DEFAULT_MAX_CONTINUATION_PASSES = 2 +private const val LEASE_STEP_TTL_MS = 45_000L +private const val TURN_TIMEOUT_MS = 45_000L +private const val HISTORY_LIMIT = 10 +private const val CAPPED_SYSTEM_TEXT = "Discussion paused (turn limit reached) — reply to continue" + class GroupChatViewModel( private var groupName: String = "", private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, @@ -54,6 +65,12 @@ class GroupChatViewModel( private val _uiState = MutableStateFlow(GroupChatUiState(groupName = groupName)) val uiState: StateFlow = _uiState.asStateFlow() + private val localDriverId = "android_" + UUID.randomUUID().toString().take(8) + private var activeEpoch: String = "" + + // Map of bot.name -> seen count of non-system messages + private val memberWatermarks = mutableMapOf() + // Map of bot.name -> active session info in this group private val memberSessions = mutableMapOf() @@ -80,6 +97,8 @@ class GroupChatViewModel( fun setGroup(name: String) { if (name == groupName && _uiState.value.groupName.isNotBlank()) return groupName = name + activeEpoch = UUID.randomUUID().toString() + memberWatermarks.clear() memberSessions.clear() inFlightTurns.clear() activeStreamMsgId.clear() @@ -252,10 +271,22 @@ class GroupChatViewModel( } } + val nonSystemCount = initialMessages.filter { !it.isSystem }.size + for (member in groupMembers) { + memberWatermarks[member.name] = nonSystemCount + } + + val maxMessages = matchingRoom?.maxBotMessages ?: DEFAULT_MAX_BOT_MESSAGES + val maxPasses = matchingRoom?.maxContinuationPasses ?: DEFAULT_MAX_CONTINUATION_PASSES + val roomSystemPrompt = matchingRoom?.systemPrompt + _uiState.update { it.copy( members = groupMembers, messages = initialMessages, + maxBotMessages = maxMessages, + maxContinuationPasses = maxPasses, + systemPrompt = roomSystemPrompt, isLoading = false, ) } @@ -270,10 +301,93 @@ class GroupChatViewModel( } } - private suspend fun ensureMemberSession(bot: ProfileInfo): MemberSession? { - val cached = memberSessions[bot.name] - if (cached != null) { - return cached + private fun findRoomEntry(rooms: Map?): Pair { + if (rooms == null) return ("name:$groupName" to null) + val exact = + rooms.entries.find { + it.key == groupName || it.key == "name:$groupName" || it.key == "id:$groupName" + } + if (exact != null) return (exact.key to exact.value) + val byName = rooms.entries.find { it.value.name.equals(groupName, ignoreCase = true) } + if (byName != null) return (byName.key to byName.value) + return ("name:$groupName" to null) + } + + fun updateGroupLimits( + maxMessages: Int, + maxPasses: Int, + systemPrompt: String? = null, + ) { + val clampedMessages = maxMessages.coerceIn(1, 20) + val clampedPasses = maxPasses.coerceIn(0, 10) + val cleanPrompt = systemPrompt?.trim()?.ifBlank { null } + + _uiState.update { + it.copy( + maxBotMessages = clampedMessages, + maxContinuationPasses = clampedPasses, + systemPrompt = cleanPrompt, + ) + } + + viewModelScope.launch(ioDispatcher) { + try { + val allProfiles = fetchProfiles() + val defaultProfile = + allProfiles.find { it.is_default == true || it.name == "default" } ?: return@launch + val existingSnapshot = + defaultProfile.groupChatSyncSnapshot() + ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap()) + + val now = System.currentTimeMillis() + val (targetKey, existingRoom) = findRoomEntry(existingSnapshot.rooms) + + val updatedRoom = + (existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = now)).copy( + name = groupName, + members = _uiState.value.members.map { JsonPrimitive(it.name) }, + maxBotMessages = clampedMessages, + maxContinuationPasses = clampedPasses, + systemPrompt = cleanPrompt, + updatedAt = now, + ) + + val updatedRooms = existingSnapshot.rooms.orEmpty().toMutableMap() + updatedRooms[targetKey] = updatedRoom + + val newSnapshot = + existingSnapshot.copy( + version = 3, + updatedAt = now, + rooms = updatedRooms, + ) + + val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) + + HermesWsClient.request( + WsMethods.PROFILES_CONFIGURE, + mapOf( + "name" to defaultProfile.name, + "ui_meta" to uiMetaPayload, + ), + ).await() + } catch (e: Exception) { + Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}") + } + } + } + + private suspend fun ensureMemberSession( + bot: ProfileInfo, + forceRefresh: Boolean = false, + ): MemberSession? { + if (!forceRefresh) { + val cached = memberSessions[bot.name] + if (cached != null) { + return cached + } + } else { + memberSessions.remove(bot.name) } val title = "Group: $groupName" @@ -298,6 +412,258 @@ class GroupChatViewModel( return null } + private suspend fun acquireOrVerifyLease( + defaultProfile: ProfileInfo, + epoch: String, + ): Boolean { + try { + val syncSnapshot = defaultProfile.groupChatSyncSnapshot() + val (targetKey, existingRoom) = findRoomEntry(syncSnapshot?.rooms) + val currentLease = existingRoom?.lease + val now = System.currentTimeMillis() + + if (currentLease?.driverId != null && + currentLease.driverId != localDriverId && + (currentLease.expiresAt ?: 0L) > now + ) { + val remainingSec = ((currentLease.expiresAt ?: 0L) - now) / 1000 + Log.i( + "GroupChatViewModel", + "Active lease held by ${currentLease.driverId} (expires in ${remainingSec}s). Passive mode.", + ) + return false + } + + val myLease = + GroupChatRoomLease( + driverId = localDriverId, + epoch = epoch, + expiresAt = now + LEASE_STEP_TTL_MS, + ) + + val existingSnapshot = syncSnapshot ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap()) + val updatedRoom = + (existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = now)).copy( + name = groupName, + members = _uiState.value.members.map { JsonPrimitive(it.name) }, + lease = myLease, + maxBotMessages = _uiState.value.maxBotMessages, + maxContinuationPasses = _uiState.value.maxContinuationPasses, + systemPrompt = _uiState.value.systemPrompt, + updatedAt = now, + ) + + val updatedRooms = existingSnapshot.rooms.orEmpty().toMutableMap() + updatedRooms[targetKey] = updatedRoom + val newSnapshot = existingSnapshot.copy(version = 3, updatedAt = now, rooms = updatedRooms) + val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) + + HermesWsClient.request( + WsMethods.PROFILES_CONFIGURE, + mapOf( + "name" to defaultProfile.name, + "ui_meta" to uiMetaPayload, + ), + ).await() + + return true + } catch (e: Exception) { + Log.w("GroupChatViewModel", "acquireOrVerifyLease failed: ${e.message}") + return true + } + } + + private suspend fun executeBotTurn( + bot: ProfileInfo, + epoch: String, + members: List, + ): String? { + if (activeEpoch != epoch) return null + + val currentNonSystemMessages = _uiState.value.messages.filter { !it.isSystem } + val seen = memberWatermarks[bot.name] ?: 0 + val delta = currentNonSystemMessages.drop(seen) + + if (delta.isEmpty() && currentNonSystemMessages.isNotEmpty()) { + return null + } + + _uiState.update { it.copy(activeSpeaker = bot.effectiveTitle) } + + val prompt = + GroupChatMentions.buildTurnPrompt( + groupName = groupName, + viewer = bot, + peers = members.filter { it.name != bot.name }, + recentLog = currentNonSystemMessages, + historyLimit = HISTORY_LIMIT, + customRoomPrompt = _uiState.value.systemPrompt, + ) + + val turnStartTime = System.currentTimeMillis() + val streamMsgId = UUID.randomUUID().toString() + var activeRuntimeId: String? = null + var activeStoredId: String? = null + try { + var sessionInfo = ensureMemberSession(bot) + if (sessionInfo == null) { + Log.w("GroupChatViewModel", "Could not obtain session for ${bot.name}") + return null + } + var runtimeId = sessionInfo.runtimeSessionId + var storedId = sessionInfo.storedSessionId + activeRuntimeId = runtimeId + activeStoredId = storedId + + activeStreamMsgId[runtimeId] = streamMsgId + storedId?.let { activeStreamMsgId[it] = streamMsgId } + + sessionToBot[runtimeId] = bot + storedId?.let { sessionToBot[it] = bot } + + ActiveSessionHolder.set(runtimeId, runtimeId) + + val turnDeferred = CompletableDeferred() + inFlightTurns[runtimeId] = turnDeferred + storedId?.let { inFlightTurns[it] = turnDeferred } + + try { + HermesWsClient.request( + WsMethods.PROMPT_SUBMIT, + mapOf("session_id" to runtimeId, "text" to prompt), + ).await() + } catch (submitErr: Exception) { + Log.w( + "GroupChatViewModel", + "prompt.submit failed for ${bot.name} on $runtimeId: ${submitErr.message}, recreating session", + ) + activeStreamMsgId.remove(runtimeId) + storedId?.let { activeStreamMsgId.remove(it) } + sessionToBot.remove(runtimeId) + storedId?.let { sessionToBot.remove(it) } + inFlightTurns.remove(runtimeId) + storedId?.let { inFlightTurns.remove(it) } + + sessionInfo = ensureMemberSession(bot, forceRefresh = true) + if (sessionInfo == null) return null + + runtimeId = sessionInfo.runtimeSessionId + storedId = sessionInfo.storedSessionId + activeRuntimeId = runtimeId + activeStoredId = storedId + + activeStreamMsgId[runtimeId] = streamMsgId + storedId?.let { activeStreamMsgId[it] = streamMsgId } + sessionToBot[runtimeId] = bot + storedId?.let { sessionToBot[it] = bot } + ActiveSessionHolder.set(runtimeId, runtimeId) + inFlightTurns[runtimeId] = turnDeferred + storedId?.let { inFlightTurns[it] = turnDeferred } + + try { + HermesWsClient.request( + WsMethods.PROMPT_SUBMIT, + mapOf("session_id" to runtimeId, "text" to prompt), + ).await() + } catch (retryErr: Exception) { + Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}") + return null + } + } + + var replyText = + withTimeoutOrNull(TURN_TIMEOUT_MS) { + turnDeferred.await() + } + inFlightTurns.remove(runtimeId) + storedId?.let { inFlightTurns.remove(it) } + + 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 { msg -> + val ts = msg.timestampEpochMs + msg.role == "assistant" && + (ts == null || ts >= (turnStartTime - 5000L)) + } + 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 (activeEpoch != epoch) { + _uiState.update { state -> + state.copy(messages = state.messages.filter { it.id != streamMsgId }) + } + return null + } + + 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 updatedList = + if (existing != null) { + state.messages.map { if (it.id == streamMsgId) finalMsg else it } + } else { + state.messages + finalMsg + } + state.copy(messages = updatedList) + } + return replyText.trim() + } else { + _uiState.update { state -> + state.copy(messages = state.messages.filter { it.id != streamMsgId }) + } + return null + } + } 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 }) + } + return null + } finally { + activeRuntimeId?.let { + activeStreamMsgId.remove(it) + sessionToBot.remove(it) + inFlightTurns.remove(it) + } + activeStoredId?.let { + activeStreamMsgId.remove(it) + sessionToBot.remove(it) + inFlightTurns.remove(it) + } + } + } + fun sendMessage(rawText: String) { val text = rawText.trim() Log.d("GroupChatViewModel", "sendMessage called with: '$text'") @@ -309,6 +675,9 @@ class GroupChatViewModel( return } + val epoch = UUID.randomUUID().toString() + activeEpoch = epoch + val userMessage = GroupChatMessage( id = UUID.randomUUID().toString(), @@ -321,142 +690,152 @@ class GroupChatViewModel( _uiState.update { it.copy(messages = it.messages + userMessage) } - persistSyncSnapshot(_uiState.value.messages) + persistSyncSnapshot(_uiState.value.messages, extendLease = true) viewModelScope.launch(ioDispatcher) { - val responders = GroupChatMentions.resolveResponders(text, members) - Log.d("GroupChatViewModel", "Responders: ${responders.map { it.name }}") + val allProfiles = fetchProfiles() + val defaultProfile = + allProfiles.find { it.is_default == true || it.name == "default" } + ?: allProfiles.firstOrNull() - 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, - ) + if (defaultProfile != null) { + val hasLease = acquireOrVerifyLease(defaultProfile, epoch) + if (!hasLease) { + Log.i("GroupChatViewModel", "Passive mode active — skipping local turn drive") + return@launch + } + } - 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 + var postedBotMessages = 0 + var continuationsCount = 0 - activeStreamMsgId[runtimeId] = streamMsgId - storedId?.let { activeStreamMsgId[it] = streamMsgId } + val maxBotLimit = _uiState.value.maxBotMessages + val maxPassLimit = _uiState.value.maxContinuationPasses - sessionToBot[runtimeId] = bot - storedId?.let { sessionToBot[it] = bot } + val initialResponders = GroupChatMentions.resolveResponders(text, members).toMutableList() + val remainingInitial = initialResponders.toMutableList() - // Set ActiveSessionHolder so the active WebSocket context matches this turn - ActiveSessionHolder.set(runtimeId, runtimeId) + // ── Round 1: Initial Turn ── + while (remainingInitial.isNotEmpty()) { + if (activeEpoch != epoch) return@launch + if (postedBotMessages >= maxBotLimit) break - // Register in-flight listener - val turnDeferred = CompletableDeferred() - inFlightTurns[runtimeId] = turnDeferred - storedId?.let { inFlightTurns[it] = turnDeferred } + val bot = remainingInitial.removeAt(0) + val reply = executeBotTurn(bot, epoch, members) + val nonSystemSize = _uiState.value.messages.filter { !it.isSystem }.size + memberWatermarks[bot.name] = nonSystemSize - // Submit prompt via sendMessage - HermesWsClient.sendMessage( - sessionId = runtimeId, - text = prompt, - ) + if (reply != null) { + postedBotMessages++ + persistSyncSnapshot(_uiState.value.messages, extendLease = true) + } + } - // Await event or polling fallback - var replyText = - withTimeoutOrNull(45_000L) { - turnDeferred.await() - } - inFlightTurns.remove(runtimeId) - storedId?.let { inFlightTurns.remove(it) } + // ── Reactive Continuation Passes ── + while (continuationsCount < maxPassLimit && postedBotMessages < maxBotLimit) { + if (activeEpoch != epoch) return@launch - // 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}") + val currentLog = _uiState.value.messages.filter { !it.isSystem } + val userIndex = currentLog.indexOfLast { it.isUser } + val recentAssistantMsgs = + if (userIndex >= 0) currentLog.drop(userIndex + 1) else currentLog + + val pendingCitedBots = mutableListOf() + for (msg in recentAssistantMsgs) { + val parsed = + GroupChatMentions.parseMentions( + text = msg.text, + members = members, + excludedSpeaker = msg.senderName, + ) + for (citedName in parsed.mentionedBots) { + val bot = members.find { it.name.equals(citedName, ignoreCase = true) } + if (bot != null && !pendingCitedBots.any { it.name == bot.name }) { + val seenCount = memberWatermarks[bot.name] ?: 0 + if (seenCount < currentLog.size) { + pendingCitedBots.add(bot) } } } + } - 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) + if (pendingCitedBots.isEmpty()) { + // Quiescence / Natural Settle reached + break + } + + continuationsCount++ + val continuationResponders = pendingCitedBots.toMutableList() + + while (continuationResponders.isNotEmpty()) { + if (activeEpoch != epoch) return@launch + if (postedBotMessages >= maxBotLimit) break + + val bot = continuationResponders.removeAt(0) + val reply = executeBotTurn(bot, epoch, members) + val nonSystemSize = _uiState.value.messages.filter { !it.isSystem }.size + memberWatermarks[bot.name] = nonSystemSize + + if (reply != null) { + postedBotMessages++ + persistSyncSnapshot(_uiState.value.messages, extendLease = true) } } } + + // ── Capped Exit Banner Check ── + val isBudgetExceeded = + postedBotMessages >= maxBotLimit || continuationsCount >= maxPassLimit + val hasUnfinishedWork = remainingInitial.isNotEmpty() || hasPendingMentions(members) + + if (isBudgetExceeded && hasUnfinishedWork && activeEpoch == epoch) { + val cappedMessage = + GroupChatMessage( + id = UUID.randomUUID().toString(), + senderName = "system", + senderDisplayName = "System", + isUser = false, + isSystem = true, + text = CAPPED_SYSTEM_TEXT, + ) + _uiState.update { it.copy(messages = it.messages + cappedMessage) } + persistSyncSnapshot(_uiState.value.messages, extendLease = false) + } + _uiState.update { it.copy(activeSpeaker = null) } } } - private fun persistSyncSnapshot(currentMessages: List) { + private fun hasPendingMentions(members: List): Boolean { + val currentLog = _uiState.value.messages.filter { !it.isSystem } + val userIndex = currentLog.indexOfLast { it.isUser } + val recentAssistantMsgs = + if (userIndex >= 0) currentLog.drop(userIndex + 1) else currentLog + + for (msg in recentAssistantMsgs) { + val parsed = + GroupChatMentions.parseMentions( + text = msg.text, + members = members, + excludedSpeaker = msg.senderName, + ) + for (citedName in parsed.mentionedBots) { + val bot = members.find { it.name.equals(citedName, ignoreCase = true) } + if (bot != null) { + val seenCount = memberWatermarks[bot.name] ?: 0 + if (seenCount < currentLog.size) { + return true + } + } + } + } + return false + } + + private fun persistSyncSnapshot( + currentMessages: List, + extendLease: Boolean = true, + ) { viewModelScope.launch(ioDispatcher) { try { val allProfiles = fetchProfiles() @@ -467,7 +846,7 @@ class GroupChatViewModel( ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap()) val logEntries = - currentMessages.takeLast(32).map { msg -> + currentMessages.filter { !it.isSystem }.takeLast(32).map { msg -> GroupChatSyncLogEntry( id = msg.id, from = @@ -481,25 +860,38 @@ class GroupChatViewModel( ) } - val roomKey = "name:$groupName" - val existingRoom = - existingSnapshot.rooms?.get(roomKey) - ?: existingSnapshot.rooms?.get(groupName) - ?: existingSnapshot.rooms?.values?.find { it.name.equals(groupName, ignoreCase = true) } + val now = System.currentTimeMillis() + val (targetKey, existingRoom) = findRoomEntry(existingSnapshot.rooms) + + val updatedLease = + if (extendLease) { + GroupChatRoomLease( + driverId = localDriverId, + epoch = activeEpoch, + expiresAt = now + LEASE_STEP_TTL_MS, + ) + } else { + existingRoom?.lease + } val updatedRoom = - (existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = System.currentTimeMillis())).copy( + (existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = now)).copy( name = groupName, members = _uiState.value.members.map { JsonPrimitive(it.name) }, log = logEntries, - updatedAt = System.currentTimeMillis(), + lease = updatedLease, + maxBotMessages = _uiState.value.maxBotMessages, + maxContinuationPasses = _uiState.value.maxContinuationPasses, + systemPrompt = _uiState.value.systemPrompt, + updatedAt = now, ) - val updatedRooms = (existingSnapshot.rooms.orEmpty() + (roomKey to updatedRoom)) + val updatedRooms = existingSnapshot.rooms.orEmpty().toMutableMap() + updatedRooms[targetKey] = updatedRoom val newSnapshot = existingSnapshot.copy( version = 3, - updatedAt = System.currentTimeMillis(), + updatedAt = now, rooms = updatedRooms, ) @@ -520,7 +912,7 @@ class GroupChatViewModel( private fun isPass(text: String): Boolean { val clean = text.trim().lowercase() - return clean == "(pass)" || clean == "pass" + return clean == "(pass)" || clean == "pass" || clean == "(pass)." || clean == "pass." } private fun extractSessionInfo(response: Any?): MemberSession? { diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e281ef44..d43eff7c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -822,4 +822,16 @@ 입력 중… 멤버 %1$d명 @all + 대화가 일시중지되었습니다(턴 한도 도달) — 계속하려면 메시지를 보내세요 + 그룹 채팅 설정 + 최대 봇 메시지 수 + 메시지당 최대 봇 응답 한도 (%1$d) + 최대 핸드오프 패스 + 봇이 서로를 트리거할 수 있는 최대 횟수 (%1$d) + 저장 + 기본값으로 초기화 + 방 지침 / 프롬프트 + 이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)… + 이 방의 모든 봇에 적용되는 사용자 지정 가이드라인 + 설정 \ 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 15c51dc7..6ee20c4a 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1121,4 +1121,16 @@ 正在输入… %1$d 个成员 @all + 讨论已暂停(达到轮次上限)— 发送消息继续 + 群聊设置 + 最大 Bot 回复数 + 单次发送的最大 Bot 回复总数 (%1$d) + 最大交接轮次 + Bot 间互相触发的最大次数 (%1$d) + 保存 + 重置为默认 + 房间指令 / 提示词 + 此群聊的自定义规则、语气或上下文(可选)… + 适用于此房间内所有 Bot 的自定义准则 + 设置 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c24ab7cf..f51bb5cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1252,4 +1252,16 @@ Typing… %1$d members @all + Discussion paused (turn limit reached) — reply to continue + Group Chat Settings + Max Bot Messages + Ceiling on total bot replies per message (%1$d) + Max Handoff Passes + How many times bots can trigger each other (%1$d) + Save + Reset to Default + Room Instructions / Prompt + Custom rules, tone, or context for this group (optional)… + Custom guidelines for all bots in this room + Settings 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 e91babd7..ff7730ae 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 @@ -3,6 +3,7 @@ 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.GroupChatRoomLease import com.m57.hermescontrol.data.model.GroupChatRoomMeta import com.m57.hermescontrol.data.model.GroupChatSyncFrom import com.m57.hermescontrol.data.model.GroupChatSyncLogEntry @@ -263,4 +264,172 @@ class GroupChatViewModelTest { assertEquals(1, finalState.messages.size) assertTrue(finalState.messages.first().isUser) } + + @Test + fun testSendMessage_reactiveContinuationHandoff() = + runTest(testDispatcher) { + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + testScheduler.runCurrent() + + // User prompts scout + viewModel.sendMessage("@scout please review the specs") + testScheduler.runCurrent() + + // Scout finishes turn and tags @coder + eventsFlow.emit( + WsEvent.MessageComplete( + text = "Specs look good, @coder please implement this!", + sessionId = "session-scout-1", + ), + ) + testScheduler.runCurrent() + + assertEquals(2, viewModel.uiState.value.messages.size) + assertEquals("scout", viewModel.uiState.value.messages[1].senderName) + + // Continuation pass triggers coder + eventsFlow.emit( + WsEvent.MessageComplete( + text = "On it! Writing unit tests now.", + sessionId = "session-scout-1", + ), + ) + testScheduler.runCurrent() + + val messages = viewModel.uiState.value.messages + assertEquals(3, messages.size) + assertEquals("coder", messages[2].senderName) + assertEquals("On it! Writing unit tests now.", messages[2].text) + } + + @Test + fun testSendMessage_passiveListenerWhenLeaseHeldByOtherDevice() = + runTest(testDispatcher) { + val activeLeaseSnapshot = + GroupChatSyncSnapshot( + version = 3, + rooms = + mapOf( + "name:Dev Team" to + GroupChatRoomMeta( + name = "Dev Team", + members = listOf(JsonPrimitive("scout"), JsonPrimitive("coder")), + lease = + GroupChatRoomLease( + driverId = "desktop_client_99", + expiresAt = System.currentTimeMillis() + 60_000L, + ), + ), + ), + ) + + val defaultProfileWithLease = + ProfileInfo( + name = "default", + is_default = true, + ui_meta = mapOf("hermes-bots-groups" to json.encodeToJsonElement(activeLeaseSnapshot)), + ) + + coEvery { mockApi.getProfiles() } returns + Response.success( + ProfilesResponse( + profiles = listOf(defaultProfileWithLease, botA, botB), + ), + ) + + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + advanceUntilIdle() + + viewModel.sendMessage("@scout check this") + testScheduler.runCurrent() + + // Because lease is held by desktop_client_99, mobile only posts the user message and yields + assertEquals(1, viewModel.uiState.value.messages.size) + assertTrue(viewModel.uiState.value.messages.first().isUser) + } + + @Test + fun testLoadGroup_loadsCustomPerGroupLimits() = + runTest(testDispatcher) { + val customLimitsSnapshot = + GroupChatSyncSnapshot( + version = 3, + rooms = + mapOf( + "name:Dev Team" to + GroupChatRoomMeta( + name = "Dev Team", + members = listOf(JsonPrimitive("scout"), JsonPrimitive("coder")), + maxBotMessages = 12, + maxContinuationPasses = 5, + systemPrompt = "You are elite software architects.", + ), + ), + ) + + val defaultProfileWithCustomLimits = + ProfileInfo( + name = "default", + is_default = true, + ui_meta = mapOf("hermes-bots-groups" to json.encodeToJsonElement(customLimitsSnapshot)), + ) + + coEvery { mockApi.getProfiles() } returns + Response.success( + ProfilesResponse( + profiles = listOf(defaultProfileWithCustomLimits, botA, botB), + ), + ) + + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(12, state.maxBotMessages) + assertEquals(5, state.maxContinuationPasses) + assertEquals("You are elite software architects.", state.systemPrompt) + } + + @Test + fun testUpdateGroupLimits_updatesStateAndClamps() = + runTest(testDispatcher) { + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + advanceUntilIdle() + + viewModel.updateGroupLimits( + maxMessages = 15, + maxPasses = 4, + systemPrompt = "Custom instructions for this group", + ) + testScheduler.runCurrent() + + assertEquals(15, viewModel.uiState.value.maxBotMessages) + assertEquals(4, viewModel.uiState.value.maxContinuationPasses) + assertEquals("Custom instructions for this group", viewModel.uiState.value.systemPrompt) + } + + @Test + fun testBuildTurnPrompt_includesCustomRoomInstructions() { + val prompt = + GroupChatMentions.buildTurnPrompt( + groupName = "Dev Team", + viewer = botA, + peers = listOf(botB), + recentLog = + listOf( + GroupChatMessage( + id = "1", + senderName = "user", + senderDisplayName = "User", + isUser = true, + text = "hello team", + ), + ), + customRoomPrompt = "You are sarcastic AI buddies.", + ) + + assertTrue(prompt.contains("Room Instructions:\nYou are sarcastic AI buddies.")) + assertTrue(prompt.contains("@scout (Scout Bot)")) + assertTrue(prompt.contains("@coder (Dev Bot)")) + } }