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

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

No files matched your search

@@ -1,8 +1,9 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.encodeToJsonElement
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
@@ -11,120 +12,132 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class ProfileSerializationTest {
private val json = OkHttpProvider.json
private val json = Json { ignoreUnknownKeys = true }
@Test
fun testProfileInfoDeserializationWithBotMetadata() {
val jsonStr =
"""
{
"name": "researcher",
"path": "/home/user/.hermes/profiles/researcher",
"is_default": false,
"model": "anthropic/claude-3-7-sonnet",
"provider": "anthropic",
"skill_count": 5,
"description": "Base description",
"display_name": "Researcher Agent",
"has_avatar": true,
"canonical_session": {
"id": "sess-canon-123",
"resolved_id": "sess-canon-tip",
"root_title": "Bot Chat",
"title": "Bot Chat",
"preview": "Latest research report ready.",
"started_at": 1700000000,
"last_active": 1700005000,
"message_count": 42
},
"worker_session": {
"id": "worker-456",
"source": "kanban",
"title": "Background analysis",
"last_active": 1700006000
},
"last_session": {
"id": "last-789",
"title": "Ad-hoc task",
"preview": "Done!",
"started_at": 1700001000,
"last_active": 1700002000,
"message_count": 10
},
"ui_meta": {
"hermes-bots": {
"title": "Dr. Researcher",
"description": "Deep literature scout",
"avatar": {
"shape": "hexagon",
"color": "#4A90E2",
"icon": "science",
"image_url": "https://example.com/avatar.png"
},
"hidden": false,
"groups": ["science-team", "daily-sync"]
}
}
}
""".trimIndent()
fun testBotRosterMetaSerialization() {
val meta =
BotRosterMeta(
title = "Literature Scout",
description = "Scrapes arXiv and summarizes papers",
avatar =
BotAvatarMeta(
shape = "circle",
color = "#4A90E2",
icon = "science",
),
hidden = false,
groups = listOf("research", "ai-lab"),
group = "research",
created = 1718000000.0,
)
val profile = json.decodeFromString<ProfileInfo>(jsonStr)
assertEquals("researcher", profile.name)
assertEquals("Researcher Agent", profile.display_name)
assertTrue(profile.has_avatar == true)
val encoded = json.encodeToString(meta)
val decoded = json.decodeFromString<BotRosterMeta>(encoded)
// Canonical session
val canon = profile.canonical_session
assertNotNull(canon)
assertEquals("sess-canon-123", canon?.id)
assertEquals("sess-canon-tip", canon?.resolved_id)
assertEquals("Latest research report ready.", canon?.preview)
assertEquals(42, canon?.message_count)
assertEquals("Literature Scout", decoded.title)
assertEquals("Scrapes arXiv and summarizes papers", decoded.description)
assertEquals("circle", decoded.avatar?.shape)
assertEquals("#4A90E2", decoded.avatar?.color)
assertEquals("science", decoded.avatar?.icon)
assertEquals(false, decoded.hidden)
assertEquals(listOf("research", "ai-lab"), decoded.groups)
assertEquals("research", decoded.group)
assertEquals(1718000000.0, decoded.created)
}
// Worker session
val worker = profile.worker_session
assertNotNull(worker)
assertEquals("worker-456", worker?.id)
assertEquals("kanban", worker?.source)
@Test
fun testProfileInfoWithBotMetaExtraction() {
val botMeta =
BotRosterMeta(
title = "Custom Bot Title",
description = "Custom Bot Description",
avatar = BotAvatarMeta(shape = "hexagon", color = "#E91E63", icon = "robot"),
hidden = false,
)
// Bot metadata decoding
val botMeta = profile.botMeta(json)
assertNotNull(botMeta)
assertEquals("Dr. Researcher", botMeta?.title)
assertEquals("Deep literature scout", botMeta?.description)
assertEquals("hexagon", botMeta?.avatar?.shape)
assertEquals("#4A90E2", botMeta?.avatar?.color)
assertEquals(listOf("science-team", "daily-sync"), botMeta?.groups)
val profile =
ProfileInfo(
name = "custom_bot",
display_name = "Original Name",
description = "Original Description",
ui_meta = mapOf("hermes-bots" to json.encodeToJsonElement(botMeta)),
canonical_session =
CanonicalSessionInfo(
id = "sess-123",
last_active = 1720000000.0,
),
worker_session =
ProfileWorkerSummary(
id = "worker-456",
source = "kanban",
title = "Task #42",
last_active = 1720000100.0,
),
)
// Helpers
assertEquals("Dr. Researcher", profile.effectiveTitle)
assertEquals("Deep literature scout", profile.effectiveDescription)
val extractedMeta = profile.botMeta(json)
assertNotNull(extractedMeta)
assertEquals("Custom Bot Title", extractedMeta?.title)
assertEquals("Custom Bot Title", profile.effectiveTitle)
assertEquals("Custom Bot Description", profile.effectiveDescription)
assertFalse(profile.isHidden)
assertEquals("sess-123", profile.canonical_session?.id)
assertEquals("worker-456", profile.worker_session?.id)
}
@Test
fun testProfileInfoWithoutBotMeta_fallsBackToProfileFields() {
val profile =
ProfileInfo(
name = "regular_profile",
display_name = "My Profile",
description = "Just a profile",
)
assertNull(profile.botMeta(json))
assertEquals("My Profile", profile.effectiveTitle)
assertEquals("Just a profile", profile.effectiveDescription)
assertFalse(profile.isHidden)
}
@Test
fun testProfileInfoBackwardCompatibility() {
val legacyJson =
"""
{
"name": "default",
"is_default": true,
"model": "openai/gpt-4o",
"provider": "openai",
"description": "Default Assistant"
}
""".trimIndent()
fun testProfileInfoHidden_derivedFromBotMeta() {
val hiddenBot =
ProfileInfo(
name = "hidden_bot",
ui_meta =
mapOf(
"hermes-bots" to
json.encodeToJsonElement(
BotRosterMeta(title = "Hidden", hidden = true),
),
),
)
val visibleBot =
ProfileInfo(
name = "visible_bot",
ui_meta =
mapOf(
"hermes-bots" to
json.encodeToJsonElement(
BotRosterMeta(title = "Visible", hidden = false),
),
),
)
assertTrue(hiddenBot.isHidden)
assertFalse(visibleBot.isHidden)
}
@Test
fun testProfileInfoDefault_fallbackNames() {
val profile = ProfileInfo(name = "default")
val profile = json.decodeFromString<ProfileInfo>(legacyJson)
assertEquals("default", profile.name)
assertTrue(profile.is_default == true)
assertNull(profile.canonical_session)
assertNull(profile.worker_session)
assertNull(profile.last_session)
assertNull(profile.ui_meta)
assertNull(profile.botMeta(json))
assertEquals("default", profile.effectiveTitle)
assertEquals("Default Assistant", profile.effectiveDescription)
assertEquals("", profile.effectiveDescription)
assertFalse(profile.isHidden)
}
@@ -140,8 +153,17 @@ class ProfileSerializationTest {
GroupChatRoomMeta(
id = "room-1",
name = "War Room",
members = listOf("default", "researcher"),
members = listOf(JsonPrimitive("default"), JsonPrimitive("researcher")),
picture = null,
log =
listOf(
GroupChatSyncLogEntry(
id = "msg-1",
from = GroupChatSyncFrom(kind = "user", name = "You"),
text = "Let's review the code",
at = 1724000000L,
),
),
updatedAt = 1724000000L,
createdAt = 1723000000L,
),
@@ -153,8 +175,11 @@ class ProfileSerializationTest {
val decoded = json.decodeFromString<GroupChatSyncSnapshot>(encoded)
assertEquals(3, decoded.version)
assertEquals(1, decoded.rooms?.size)
assertEquals("War Room", decoded.rooms?.get("room-1")?.name)
assertEquals(listOf("default", "researcher"), decoded.rooms?.get("room-1")?.members)
val room = decoded.rooms?.get("room-1")
assertEquals("War Room", room?.name)
assertEquals(listOf("default", "researcher"), room?.memberNames)
assertEquals(1, room?.log?.size)
assertEquals("Let's review the code", room?.log?.first()?.text)
assertEquals(1723500000L, decoded.deleted?.get("old-room"))
}
}
@@ -0,0 +1,71 @@
package com.m57.hermescontrol.ui.bots.group
import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.BotRosterMeta
import com.m57.hermescontrol.data.model.ProfileInfo
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class GroupChatMentionsTest {
private val json = Json { ignoreUnknownKeys = true }
private val botA =
ProfileInfo(
name = "scoutbot",
ui_meta =
mapOf(
"hermes-bots" to
json.encodeToJsonElement(
BotRosterMeta(title = "Scout Bot", avatar = BotAvatarMeta()),
),
),
)
private val botB =
ProfileInfo(
name = "coder",
ui_meta =
mapOf(
"hermes-bots" to
json.encodeToJsonElement(
BotRosterMeta(title = "Dev Bot", avatar = BotAvatarMeta()),
),
),
)
private val members = listOf(botA, botB)
@Test
fun parseMentions_detectsDirectBotHandle() {
val result = GroupChatMentions.parseMentions("Hey @scoutbot can you check this?", members)
assertFalse(result.isEveryone)
assertTrue(result.mentionedBots.contains("scoutbot"))
assertFalse(result.mentionedBots.contains("coder"))
}
@Test
fun parseMentions_detectsEveryoneAndAll() {
val resEveryone = GroupChatMentions.parseMentions("Hello @everyone!", members)
assertTrue(resEveryone.isEveryone)
val resAll = GroupChatMentions.parseMentions("@all status update please", members)
assertTrue(resAll.isEveryone)
}
@Test
fun resolveResponders_defaultsToAllWhenNoMention() {
val responders = GroupChatMentions.resolveResponders("General discussion message", members)
assertEquals(2, responders.size)
}
@Test
fun resolveResponders_filtersToMentionedOnly() {
val responders = GroupChatMentions.resolveResponders("@coder build the apk", members)
assertEquals(1, responders.size)
assertEquals("coder", responders.first().name)
}
}
@@ -0,0 +1,266 @@
package com.m57.hermescontrol.ui.bots.group
import android.util.Log
import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.BotRosterMeta
import com.m57.hermescontrol.data.model.GroupChatRoomMeta
import com.m57.hermescontrol.data.model.GroupChatSyncFrom
import com.m57.hermescontrol.data.model.GroupChatSyncLogEntry
import com.m57.hermescontrol.data.model.GroupChatSyncSnapshot
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.encodeToJsonElement
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class GroupChatViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService
private val eventsFlow = MutableSharedFlow<WsEvent>()
private val json = Json { ignoreUnknownKeys = true }
private val botA =
ProfileInfo(
name = "scout",
ui_meta =
mapOf(
"hermes-bots" to
json.encodeToJsonElement(
BotRosterMeta(
title = "Scout Bot",
groups = listOf("Dev Team"),
avatar = BotAvatarMeta(icon = "science"),
),
),
),
)
private val botB =
ProfileInfo(
name = "coder",
ui_meta =
mapOf(
"hermes-bots" to
json.encodeToJsonElement(
BotRosterMeta(
title = "Dev Bot",
groups = listOf("Dev Team"),
avatar = BotAvatarMeta(icon = "code"),
),
),
),
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any(), any<String>()) } returns 0
every { Log.e(any(), any()) } returns 0
mockkObject(ApiClient)
mockApi = mockk(relaxed = true)
every { ApiClient.hermesApi } returns mockApi
coEvery { mockApi.getProfiles() } returns
Response.success(
ProfilesResponse(
profiles = listOf(botA, botB),
),
)
mockkObject(HermesWsClient)
every { HermesWsClient.events } returns eventsFlow
every { HermesWsClient.send(any(), any(), any()) } returns "req-1"
every { HermesWsClient.sendMessage(any(), any(), any()) } returns "req-msg"
val createDeferred = CompletableDeferred<Any?>()
createDeferred.complete(mapOf("session_id" to "session-scout-1"))
every { HermesWsClient.request(any(), any(), any()) } returns createDeferred
}
@After
fun tearDown() {
unmockkObject(ApiClient)
unmockkObject(HermesWsClient)
unmockkStatic(Log::class)
Dispatchers.resetMain()
}
@Test
fun testLoadGroup_resolvesMembers() =
runTest(testDispatcher) {
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.isLoading)
assertNull(state.errorMessage)
assertEquals(2, state.members.size)
assertEquals("scout", state.members[0].name)
assertEquals("coder", state.members[1].name)
}
@Test
fun testLoadGroup_hydratesFromSyncSnapshot() =
runTest(testDispatcher) {
val syncSnapshot =
GroupChatSyncSnapshot(
version = 3,
updatedAt = 1724000000L,
rooms =
mapOf(
"name:Dev Team" to
GroupChatRoomMeta(
name = "Dev Team",
members = listOf(JsonPrimitive("scout"), JsonPrimitive("coder")),
log =
listOf(
GroupChatSyncLogEntry(
id = "msg-prev-1",
from = GroupChatSyncFrom(kind = "user", name = "You"),
text = "Initial design spec",
at = 1724000000L,
),
GroupChatSyncLogEntry(
id = "msg-prev-2",
from = GroupChatSyncFrom(kind = "member", name = "scout"),
text = "Looks solid, ready to build.",
at = 1724000010L,
),
),
),
),
)
val defaultProfileWithSnapshot =
ProfileInfo(
name = "default",
is_default = true,
ui_meta = mapOf("hermes-bots-groups" to json.encodeToJsonElement(syncSnapshot)),
)
coEvery { mockApi.getProfiles() } returns
Response.success(
ProfilesResponse(
profiles = listOf(defaultProfileWithSnapshot, botA, botB),
),
)
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(2, state.messages.size)
assertEquals("msg-prev-1", state.messages[0].id)
assertTrue(state.messages[0].isUser)
assertEquals("Initial design spec", state.messages[0].text)
assertEquals("msg-prev-2", state.messages[1].id)
assertFalse(state.messages[1].isUser)
assertEquals("scout", state.messages[1].senderName)
assertEquals("Looks solid, ready to build.", state.messages[1].text)
}
@Test
fun testSendMessage_streamsTokensAndCompletes() =
runTest(testDispatcher) {
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
testScheduler.runCurrent()
// Send message targeting only @scout
viewModel.sendMessage("@scout what's the status?")
testScheduler.runCurrent()
// State should contain user message
val userMsg = viewModel.uiState.value.messages.first()
assertTrue(userMsg.isUser)
assertEquals("@scout what's the status?", userMsg.text)
// Stream tokens
eventsFlow.emit(WsEvent.MessageToken(token = "Building ", sessionId = "session-scout-1"))
testScheduler.runCurrent()
val streamingMsg = viewModel.uiState.value.messages.last()
assertFalse(streamingMsg.isUser)
assertEquals("scout", streamingMsg.senderName)
assertEquals("Building ", streamingMsg.text)
assertTrue(streamingMsg.isStreaming)
eventsFlow.emit(WsEvent.MessageToken(token = "the APK...", sessionId = "session-scout-1"))
testScheduler.runCurrent()
val updatedStreaming = viewModel.uiState.value.messages.last()
assertEquals("Building the APK...", updatedStreaming.text)
// Complete turn
eventsFlow.emit(
WsEvent.MessageComplete(
text = "Building the APK now! Everything looks green.",
sessionId = "session-scout-1",
),
)
testScheduler.runCurrent()
val finalState = viewModel.uiState.value
assertEquals(2, finalState.messages.size)
val botMsg = finalState.messages[1]
assertEquals("Building the APK now! Everything looks green.", botMsg.text)
assertFalse(botMsg.isStreaming)
assertNull(finalState.activeSpeaker)
}
@Test
fun testSendMessage_filtersOutPass() =
runTest(testDispatcher) {
val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
testScheduler.runCurrent()
viewModel.sendMessage("@scout any updates?")
testScheduler.runCurrent()
eventsFlow.emit(
WsEvent.MessageComplete(
text = "(pass)",
sessionId = "session-scout-1",
),
)
testScheduler.runCurrent()
val finalState = viewModel.uiState.value
// Only the user message should remain; (pass) is filtered out
assertEquals(1, finalState.messages.size)
assertTrue(finalState.messages.first().isUser)
}
}