feat(models): support bot metadata, canonical sessions, and group sync in Profile models (#973)

This commit is contained in:
M57 authored and GitHub committed 2026-08-27 01:48:43 +03:00
1 parent 4f3a57e340
commit c028077647
2 files changed
+269

No files matched your search

@@ -1,9 +1,14 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
@Serializable
data class ProfilesResponse(
val profiles: List<ProfileInfo>,
val bot_mode_protocol: Boolean? = null,
)
@Serializable
@@ -17,7 +22,111 @@ data class ProfileInfo(
val skill_count: Int? = null,
val gateway_running: Boolean? = null,
val description: String? = null,
val display_name: String? = null,
val description_auto: Boolean? = null,
val has_avatar: Boolean? = null,
val ui_meta: Map<String, JsonElement>? = null,
val ui_meta_revisions: Map<String, Int>? = null,
val canonical_session: CanonicalSessionInfo? = null,
val last_session: ProfileSessionSummary? = null,
val worker_session: ProfileWorkerSummary? = null,
) {
fun botMeta(
json: Json =
Json {
ignoreUnknownKeys = true
isLenient = true
},
): BotRosterMeta? {
val element = ui_meta?.get("hermes-bots") ?: return null
return try {
json.decodeFromJsonElement<BotRosterMeta>(element)
} catch (_: Exception) {
null
}
}
val effectiveTitle: String
get() =
botMeta()?.title?.takeIf { it.isNotBlank() }
?: display_name?.takeIf { it.isNotBlank() }
?: name
val effectiveDescription: String
get() =
botMeta()?.description?.takeIf { it.isNotBlank() }
?: description
?: ""
val isHidden: Boolean
get() = botMeta()?.hidden == true
}
@Serializable
data class CanonicalSessionInfo(
val id: String,
val resolved_id: String? = null,
val root_title: String? = null,
val title: String? = null,
val preview: String? = null,
val started_at: Long? = null,
val last_active: Long? = null,
val message_count: Int? = null,
)
@Serializable
data class ProfileSessionSummary(
val id: String,
val title: String? = null,
val preview: String? = null,
val started_at: Long? = null,
val last_active: Long? = null,
val message_count: Int? = null,
)
@Serializable
data class ProfileWorkerSummary(
val id: String,
val source: String? = null,
val title: String? = null,
val last_active: Long? = null,
)
@Serializable
data class BotRosterMeta(
val title: String? = null,
val description: String? = null,
val avatar: BotAvatarMeta? = null,
val hidden: Boolean? = null,
val groups: List<String>? = null,
val group: String? = null,
val created: Long? = null,
)
@Serializable
data class BotAvatarMeta(
val shape: String? = null,
val color: String? = null,
val icon: String? = null,
val image_url: String? = null,
)
@Serializable
data class GroupChatSyncSnapshot(
val version: Int? = null,
val updatedAt: Long? = null,
val rooms: Map<String, GroupChatRoomMeta>? = null,
val deleted: Map<String, Long>? = null,
)
@Serializable
data class GroupChatRoomMeta(
val id: String,
val name: String? = null,
val members: List<String>? = null,
val picture: String? = null,
val updatedAt: Long? = null,
val createdAt: Long? = null,
)
@Serializable
@@ -0,0 +1,160 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ProfileSerializationTest {
private val json = OkHttpProvider.json
@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()
val profile = json.decodeFromString<ProfileInfo>(jsonStr)
assertEquals("researcher", profile.name)
assertEquals("Researcher Agent", profile.display_name)
assertTrue(profile.has_avatar == true)
// 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)
// Worker session
val worker = profile.worker_session
assertNotNull(worker)
assertEquals("worker-456", worker?.id)
assertEquals("kanban", worker?.source)
// 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)
// Helpers
assertEquals("Dr. Researcher", profile.effectiveTitle)
assertEquals("Deep literature scout", 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()
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)
assertFalse(profile.isHidden)
}
@Test
fun testGroupChatSyncSnapshotSerialization() {
val snapshot =
GroupChatSyncSnapshot(
version = 3,
updatedAt = 1724000000L,
rooms =
mapOf(
"room-1" to
GroupChatRoomMeta(
id = "room-1",
name = "War Room",
members = listOf("default", "researcher"),
picture = null,
updatedAt = 1724000000L,
createdAt = 1723000000L,
),
),
deleted = mapOf("old-room" to 1723500000L),
)
val encoded = json.encodeToString(snapshot)
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)
assertEquals(1723500000L, decoded.deleted?.get("old-room"))
}
}