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"))
}
}