From cb0463aefc84c23498e8b6f5b76fe967db3af400 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:42:01 +0300 Subject: [PATCH] refactor(#1375): type profiles.list and profiles.configure RPC params (#1392) --- .../ws/contract/ProfilesConfigureParams.kt | 16 ++++ .../data/ws/contract/ProfilesListParams.kt | 7 ++ .../data/ws/contract/RpcMethod.kt | 18 ++++ .../hermescontrol/ui/bots/BotsViewModel.kt | 83 ++++++++++--------- .../ui/bots/group/GroupChatViewModel.kt | 60 ++++++-------- .../data/ws/RawRpcParamsRatchetTest.kt | 8 -- .../data/ws/contract/ProfilesTypesTest.kt | 54 ++++++++++++ 7 files changed, 164 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesConfigureParams.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesListParams.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProfilesTypesTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesConfigureParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesConfigureParams.kt new file mode 100644 index 00000000..88807989 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesConfigureParams.kt @@ -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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesListParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesListParams.kt new file mode 100644 index 00000000..0b5f7dda --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesListParams.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index 4b59ac9c..c2020fb4 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -379,6 +379,22 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROFILES_LIST: RpcMethod = + RpcMethod( + WsMethods.PROFILES_LIST, + ProfilesListParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROFILES_CONFIGURE: RpcMethod = + RpcMethod( + WsMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -428,5 +444,7 @@ object RpcMethods { PROCESS_KILL, PLUGINS_MANAGE, PROJECTS_LIST, + PROFILES_LIST, + PROFILES_CONFIGURE, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt index 124af95b..d7439f2c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt @@ -16,7 +16,9 @@ import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator 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.ui.common.ToastHost import kotlinx.coroutines.CoroutineDispatcher @@ -29,6 +31,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive 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). var profilesWithMeta: List? = null try { - val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await() - val jsonElement = - when (rpcResult) { - is JsonElement -> rpcResult - null -> null - else -> rpcResult.toJsonElement() - } - if (jsonElement != null) { + val jsonElement = HermesWsClient.call(RpcMethods.PROFILES_LIST, ProfilesListParams) + if (jsonElement !is JsonNull) { val resp = OkHttpProvider.json.decodeFromJsonElement(jsonElement) if (!resp.profiles.isNullOrEmpty()) { profilesWithMeta = resp.profiles @@ -362,7 +360,13 @@ class BotsViewModel( ), ) val botSoul = composeBotSoul(name, title, description) - wsClientConfigureBot(name, botMeta, soul = botSoul) + // Fire-and-forget as before: the metadata write never gated the reload or onSuccess. + launch { + try { + wsClientConfigureBot(name, botMeta, soul = botSoul) + } catch (_: Exception) { + } + } loadBots() onSuccess() } else { @@ -396,7 +400,7 @@ class BotsViewModel( ), ) try { - wsClientConfigureBot(name, updatedMeta).await() + wsClientConfigureBot(name, updatedMeta) } catch (_: Exception) { } loadBots() @@ -440,7 +444,7 @@ class BotsViewModel( group = existingGroups.firstOrNull() ?: groupName, ) try { - wsClientConfigureBot(name, updatedMeta).await() + wsClientConfigureBot(name, updatedMeta) } catch (_: Exception) { } } @@ -475,14 +479,13 @@ class BotsViewModel( rooms = updatedRooms, deleted = updatedDeleted, ) - HermesWsClient - .request( - WsMethods.PROFILES_CONFIGURE, - mapOf( - "name" to defaultProfile.name, - "ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()), - ), - ).await() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = mapOf("hermes-bots-groups" to newSnapshot.toMap()).toJsonObject(), + ), + ) } } catch (_: Exception) { } @@ -509,7 +512,7 @@ class BotsViewModel( group = filtered.firstOrNull(), ) try { - wsClientConfigureBot(bot.name, updatedMeta).await() + wsClientConfigureBot(bot.name, updatedMeta) } catch (_: Exception) { } } @@ -540,14 +543,13 @@ class BotsViewModel( rooms = updatedRooms, deleted = deletedMap, ) - HermesWsClient - .request( - WsMethods.PROFILES_CONFIGURE, - mapOf( - "name" to defaultProfile.name, - "ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()), - ), - ).await() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = mapOf("hermes-bots-groups" to newSnapshot.toMap()).toJsonObject(), + ), + ) } } } catch (_: Exception) { @@ -575,11 +577,11 @@ class BotsViewModel( return lines.joinToString("\n") } - private fun wsClientConfigureBot( + private suspend fun wsClientConfigureBot( name: String, meta: BotRosterMeta, soul: String? = null, - ): kotlinx.coroutines.CompletableDeferred { + ) { val metaMap = buildMap { meta.title?.let { put("title", it) } @@ -599,16 +601,15 @@ class BotsViewModel( } } - val params = - buildMap { - put("name", name) - put("ui_meta", mapOf("hermes-bots" to metaMap)) - soul?.let { put("soul", it) } - } - - return HermesWsClient.request( - WsMethods.PROFILES_CONFIGURE, - params, + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = name, + uiMeta = mapOf("hermes-bots" to metaMap).toJsonObject(), + soul = soul, + ), ) } + + private fun Map.toJsonObject(): JsonObject = toJsonElement() as JsonObject } 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 ca8b2e5d..52940d82 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 @@ -16,7 +16,8 @@ import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.HermesWsClient 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.RpcMethods import com.m57.hermescontrol.data.ws.contract.SessionCreateParams @@ -34,6 +35,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement import java.util.UUID @@ -327,14 +330,8 @@ class GroupChatViewModel( private suspend fun fetchProfiles(): List { try { - val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await() - val jsonElement = - when (rpcResult) { - is JsonElement -> rpcResult - null -> null - else -> rpcResult.toJsonElement() - } - if (jsonElement != null) { + val jsonElement = HermesWsClient.call(RpcMethods.PROFILES_LIST, ProfilesListParams) + if (jsonElement !is JsonNull) { val resp = json.decodeFromJsonElement(jsonElement) if (!resp.profiles.isNullOrEmpty()) { return resp.profiles @@ -534,14 +531,13 @@ class GroupChatViewModel( val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) - HermesWsClient - .request( - WsMethods.PROFILES_CONFIGURE, - mapOf( - "name" to defaultProfile.name, - "ui_meta" to uiMetaPayload, - ), - ).await() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = uiMetaPayload.toJsonElement() as JsonObject, + ), + ) } catch (e: Exception) { Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}") } @@ -636,14 +632,13 @@ class GroupChatViewModel( 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() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = uiMetaPayload.toJsonElement() as JsonObject, + ), + ) return true } catch (e: Exception) { @@ -1193,14 +1188,13 @@ class GroupChatViewModel( val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) - HermesWsClient - .request( - WsMethods.PROFILES_CONFIGURE, - mapOf( - "name" to defaultProfile.name, - "ui_meta" to uiMetaPayload, - ), - ).await() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = uiMetaPayload.toJsonElement() as JsonObject, + ), + ) } catch (e: Exception) { Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}") } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index 6768ad61..64595925 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -318,14 +318,6 @@ class RawRpcParamsRatchetTest { listOf( "data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES", "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", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProfilesTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProfilesTypesTest.kt new file mode 100644 index 00000000..1083f9bc --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProfilesTypesTest.kt @@ -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) + } +}