refactor(#1375): type profiles.list and profiles.configure RPC params (#1392)

This commit is contained in:
m57 authored and GitHub committed 2026-09-29 23:42:01 +03:00
1 parent 445da9fe14
commit cb0463aefc
7 files changed
+158 -76

No files matched your search

@@ -0,0 +1,16 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/**
* `profiles.configure` params for the sections the app writes: `ui_meta` and the bot `soul`.
* `profile` is not a field: WsProfileParams injects it when the method is profile scoped.
*/
@Serializable
data class ProfilesConfigureParams(
@SerialName("name") val name: String? = null,
@SerialName("ui_meta") val uiMeta: JsonObject? = null,
@SerialName("soul") val soul: String? = null,
)
@@ -0,0 +1,7 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.Serializable
/** `profiles.list` takes no explicit params here; the active profile is injected by WsProfileParams. */
@Serializable
data object ProfilesListParams
@@ -379,6 +379,22 @@ object RpcMethods {
JsonElement.serializer(), JsonElement.serializer(),
) )
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val PROFILES_LIST: RpcMethod<ProfilesListParams, JsonElement> =
RpcMethod(
WsMethods.PROFILES_LIST,
ProfilesListParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val PROFILES_CONFIGURE: RpcMethod<ProfilesConfigureParams, JsonElement> =
RpcMethod(
WsMethods.PROFILES_CONFIGURE,
ProfilesConfigureParams.serializer(),
JsonElement.serializer(),
)
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */ /** Every typed method. GatewayContractTest iterates this; future migrations append here. */
val all: List<RpcMethod<*, *>> = val all: List<RpcMethod<*, *>> =
listOf( listOf(
@@ -428,5 +444,7 @@ object RpcMethods {
PROCESS_KILL, PROCESS_KILL,
PLUGINS_MANAGE, PLUGINS_MANAGE,
PROJECTS_LIST, PROJECTS_LIST,
PROFILES_LIST,
PROFILES_CONFIGURE,
) )
} }
@@ -16,7 +16,9 @@ import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams
import com.m57.hermescontrol.data.ws.contract.ProfilesListParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -29,6 +31,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
@@ -243,14 +247,8 @@ class BotsViewModel(
// First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars). // First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
var profilesWithMeta: List<ProfileInfo>? = null var profilesWithMeta: List<ProfileInfo>? = null
try { try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await() val jsonElement = HermesWsClient.call(RpcMethods.PROFILES_LIST, ProfilesListParams)
val jsonElement = if (jsonElement !is JsonNull) {
when (rpcResult) {
is JsonElement -> rpcResult
null -> null
else -> rpcResult.toJsonElement()
}
if (jsonElement != null) {
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement) val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
if (!resp.profiles.isNullOrEmpty()) { if (!resp.profiles.isNullOrEmpty()) {
profilesWithMeta = resp.profiles profilesWithMeta = resp.profiles
@@ -362,7 +360,13 @@ class BotsViewModel(
), ),
) )
val botSoul = composeBotSoul(name, title, description) val botSoul = composeBotSoul(name, title, description)
// Fire-and-forget as before: the metadata write never gated the reload or onSuccess.
launch {
try {
wsClientConfigureBot(name, botMeta, soul = botSoul) wsClientConfigureBot(name, botMeta, soul = botSoul)
} catch (_: Exception) {
}
}
loadBots() loadBots()
onSuccess() onSuccess()
} else { } else {
@@ -396,7 +400,7 @@ class BotsViewModel(
), ),
) )
try { try {
wsClientConfigureBot(name, updatedMeta).await() wsClientConfigureBot(name, updatedMeta)
} catch (_: Exception) { } catch (_: Exception) {
} }
loadBots() loadBots()
@@ -440,7 +444,7 @@ class BotsViewModel(
group = existingGroups.firstOrNull() ?: groupName, group = existingGroups.firstOrNull() ?: groupName,
) )
try { try {
wsClientConfigureBot(name, updatedMeta).await() wsClientConfigureBot(name, updatedMeta)
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
@@ -475,14 +479,13 @@ class BotsViewModel(
rooms = updatedRooms, rooms = updatedRooms,
deleted = updatedDeleted, deleted = updatedDeleted,
) )
HermesWsClient HermesWsClient.call(
.request( RpcMethods.PROFILES_CONFIGURE,
WsMethods.PROFILES_CONFIGURE, ProfilesConfigureParams(
mapOf( name = defaultProfile.name,
"name" to defaultProfile.name, uiMeta = mapOf("hermes-bots-groups" to newSnapshot.toMap()).toJsonObject(),
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
), ),
).await() )
} }
} catch (_: Exception) { } catch (_: Exception) {
} }
@@ -509,7 +512,7 @@ class BotsViewModel(
group = filtered.firstOrNull(), group = filtered.firstOrNull(),
) )
try { try {
wsClientConfigureBot(bot.name, updatedMeta).await() wsClientConfigureBot(bot.name, updatedMeta)
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
@@ -540,14 +543,13 @@ class BotsViewModel(
rooms = updatedRooms, rooms = updatedRooms,
deleted = deletedMap, deleted = deletedMap,
) )
HermesWsClient HermesWsClient.call(
.request( RpcMethods.PROFILES_CONFIGURE,
WsMethods.PROFILES_CONFIGURE, ProfilesConfigureParams(
mapOf( name = defaultProfile.name,
"name" to defaultProfile.name, uiMeta = mapOf("hermes-bots-groups" to newSnapshot.toMap()).toJsonObject(),
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
), ),
).await() )
} }
} }
} catch (_: Exception) { } catch (_: Exception) {
@@ -575,11 +577,11 @@ class BotsViewModel(
return lines.joinToString("\n") return lines.joinToString("\n")
} }
private fun wsClientConfigureBot( private suspend fun wsClientConfigureBot(
name: String, name: String,
meta: BotRosterMeta, meta: BotRosterMeta,
soul: String? = null, soul: String? = null,
): kotlinx.coroutines.CompletableDeferred<Any?> { ) {
val metaMap = val metaMap =
buildMap<String, Any> { buildMap<String, Any> {
meta.title?.let { put("title", it) } meta.title?.let { put("title", it) }
@@ -599,16 +601,15 @@ class BotsViewModel(
} }
} }
val params = HermesWsClient.call(
buildMap<String, Any> { RpcMethods.PROFILES_CONFIGURE,
put("name", name) ProfilesConfigureParams(
put("ui_meta", mapOf("hermes-bots" to metaMap)) name = name,
soul?.let { put("soul", it) } uiMeta = mapOf("hermes-bots" to metaMap).toJsonObject(),
} soul = soul,
),
return HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
params,
) )
} }
private fun Map<String, Any?>.toJsonObject(): JsonObject = toJsonElement() as JsonObject
} }
@@ -16,7 +16,8 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams
import com.m57.hermescontrol.data.ws.contract.ProfilesListParams
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionCreateParams import com.m57.hermescontrol.data.ws.contract.SessionCreateParams
@@ -34,6 +35,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import java.util.UUID import java.util.UUID
@@ -327,14 +330,8 @@ class GroupChatViewModel(
private suspend fun fetchProfiles(): List<ProfileInfo> { private suspend fun fetchProfiles(): List<ProfileInfo> {
try { try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await() val jsonElement = HermesWsClient.call(RpcMethods.PROFILES_LIST, ProfilesListParams)
val jsonElement = if (jsonElement !is JsonNull) {
when (rpcResult) {
is JsonElement -> rpcResult
null -> null
else -> rpcResult.toJsonElement()
}
if (jsonElement != null) {
val resp = json.decodeFromJsonElement<ProfilesResponse>(jsonElement) val resp = json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
if (!resp.profiles.isNullOrEmpty()) { if (!resp.profiles.isNullOrEmpty()) {
return resp.profiles return resp.profiles
@@ -534,14 +531,13 @@ class GroupChatViewModel(
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient HermesWsClient.call(
.request( RpcMethods.PROFILES_CONFIGURE,
WsMethods.PROFILES_CONFIGURE, ProfilesConfigureParams(
mapOf( name = defaultProfile.name,
"name" to defaultProfile.name, uiMeta = uiMetaPayload.toJsonElement() as JsonObject,
"ui_meta" to uiMetaPayload,
), ),
).await() )
} catch (e: Exception) { } catch (e: Exception) {
Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}") Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}")
} }
@@ -636,14 +632,13 @@ class GroupChatViewModel(
val newSnapshot = existingSnapshot.copy(version = 3, updatedAt = now, rooms = updatedRooms) val newSnapshot = existingSnapshot.copy(version = 3, updatedAt = now, rooms = updatedRooms)
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient HermesWsClient.call(
.request( RpcMethods.PROFILES_CONFIGURE,
WsMethods.PROFILES_CONFIGURE, ProfilesConfigureParams(
mapOf( name = defaultProfile.name,
"name" to defaultProfile.name, uiMeta = uiMetaPayload.toJsonElement() as JsonObject,
"ui_meta" to uiMetaPayload,
), ),
).await() )
return true return true
} catch (e: Exception) { } catch (e: Exception) {
@@ -1193,14 +1188,13 @@ class GroupChatViewModel(
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient HermesWsClient.call(
.request( RpcMethods.PROFILES_CONFIGURE,
WsMethods.PROFILES_CONFIGURE, ProfilesConfigureParams(
mapOf( name = defaultProfile.name,
"name" to defaultProfile.name, uiMeta = uiMetaPayload.toJsonElement() as JsonObject,
"ui_meta" to uiMetaPayload,
), ),
).await() )
} catch (e: Exception) { } catch (e: Exception) {
Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}") Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}")
} }
@@ -318,14 +318,6 @@ class RawRpcParamsRatchetTest {
listOf( listOf(
"data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES", "data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES",
"data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS", "data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/BotsViewModel.kt::PROFILES_LIST",
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/group/GroupChatViewModel.kt::PROFILES_LIST",
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
@@ -0,0 +1,54 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ProfilesTypesTest {
private fun encode(params: ProfilesConfigureParams) =
OkHttpProvider.json.encodeToJsonElement(ProfilesConfigureParams.serializer(), params)
@Test
fun configureEncodesNameAndUiMetaOnly() {
val meta = buildJsonObject { put("hermes-bots-groups", buildJsonObject { put("version", 3) }) }
assertEquals(
buildJsonObject {
put("name", "default")
put("ui_meta", meta)
},
encode(ProfilesConfigureParams(name = "default", uiMeta = meta)),
)
}
@Test
fun configureIncludesSoulWhenPresent() {
val meta = buildJsonObject { put("hermes-bots", buildJsonObject { put("title", "Scout") }) }
assertEquals(
buildJsonObject {
put("name", "scout")
put("ui_meta", meta)
put("soul", "# Scout")
},
encode(ProfilesConfigureParams(name = "scout", uiMeta = meta, soul = "# Scout")),
)
}
@Test
fun listEncodesAsEmptyObject() {
assertEquals(
buildJsonObject { },
OkHttpProvider.json.encodeToJsonElement(ProfilesListParams.serializer(), ProfilesListParams),
)
}
@Test
fun descriptorsAreRegisteredWithContractNames() {
assertTrue(RpcMethods.PROFILES_LIST in RpcMethods.all)
assertTrue(RpcMethods.PROFILES_CONFIGURE in RpcMethods.all)
assertEquals("profiles.list", RpcMethods.PROFILES_LIST.name)
assertEquals("profiles.configure", RpcMethods.PROFILES_CONFIGURE.name)
}
}