refactor(#1375): type subagent list and tail RPC params (#1389)

This commit is contained in:
m57 authored and GitHub committed 2026-09-29 22:02:07 +03:00
1 parent c8edf3665f
commit 27ce528005
6 files changed
+111 -27

No files matched your search

@@ -3,6 +3,9 @@ package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.model.SubagentListResponse
import com.m57.hermescontrol.data.model.SubagentTailResponse
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionIdParams
import com.m57.hermescontrol.data.ws.contract.SubagentTailParams
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
@@ -33,12 +36,11 @@ object SubagentRepository {
if (sessionId.isBlank()) return null
return try {
val result =
HermesWsClient
.request(
WsMethods.SUBAGENT_LIST,
mapOf("session_id" to sessionId),
suppressErrorEvent = true,
).await()
HermesWsClient.call(
RpcMethods.SUBAGENT_LIST,
SessionIdParams(sessionId),
suppressErrorEvent = true,
)
decode<SubagentListResponse>(result)
} catch (e: Exception) {
Log.w(TAG, "subagent.list request failed for session $sessionId: ${e.message}")
@@ -58,18 +60,12 @@ object SubagentRepository {
): SubagentTailResponse? {
// Contract (SubagentIdParams): session_id and subagent_id are required, extra keys forbidden (#1379).
if (subagentId.isBlank() || sessionId.isBlank()) return null
val params =
mapOf<String, Any>(
"session_id" to sessionId,
"subagent_id" to subagentId,
)
val result =
HermesWsClient
.request(
WsMethods.SUBAGENT_TAIL,
params,
suppressErrorEvent = true,
).await()
HermesWsClient.call(
RpcMethods.SUBAGENT_TAIL,
SubagentTailParams(sessionId, subagentId),
suppressErrorEvent = true,
)
return decode<SubagentTailResponse>(result)
}
@@ -331,6 +331,22 @@ object RpcMethods {
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val SUBAGENT_LIST: RpcMethod<SessionIdParams, JsonElement> =
RpcMethod(
WsMethods.SUBAGENT_LIST,
SessionIdParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val SUBAGENT_TAIL: RpcMethod<SubagentTailParams, JsonElement> =
RpcMethod(
WsMethods.SUBAGENT_TAIL,
SubagentTailParams.serializer(),
JsonElement.serializer(),
)
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */
val all: List<RpcMethod<*, *>> =
listOf(
@@ -374,5 +390,7 @@ object RpcMethods {
CONNECTORS_TOOLS,
CONNECTION_RESPOND,
CONNECTORS_OPERATION_WAKE,
SUBAGENT_LIST,
SUBAGENT_TAIL,
)
}
@@ -0,0 +1,10 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SubagentTailParams(
@SerialName("session_id") val sessionId: String,
@SerialName("subagent_id") val subagentId: String,
)
@@ -322,8 +322,6 @@ class RawRpcParamsRatchetTest {
"data/ws/PluginManageRepository.kt::PLUGINS_MANAGE",
"data/ws/PluginRemovalRepository.kt::PLUGINS_MANAGE",
"data/ws/ProjectsSource.kt::PROJECTS_LIST",
"data/ws/SubagentRepository.kt::SUBAGENT_LIST",
"data/ws/SubagentRepository.kt::SUBAGENT_TAIL",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
@@ -3,13 +3,17 @@ package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.SubagentListItem
import com.m57.hermescontrol.data.model.SubagentListResponse
import com.m57.hermescontrol.data.model.SubagentTailResponse
import io.mockk.every
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionIdParams
import com.m57.hermescontrol.data.ws.contract.SubagentTailParams
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockkObject
import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -24,17 +28,31 @@ class SubagentRepositoryTest {
unmockkAll()
}
@Test
fun listSubagentsPreservesSuppressedErrorsAndDecodesResult() {
val captured = slot<SessionIdParams>()
mockkObject(HermesWsClient)
coEvery { HermesWsClient.call(RpcMethods.SUBAGENT_LIST, capture(captured), any(), true) } returns
buildJsonObject { put("subagents", kotlinx.serialization.json.buildJsonArray { }) }
val response = runBlocking { SubagentRepository.listSubagents("s1") }
assertEquals(SessionIdParams("s1"), captured.captured)
assertNotNull(response)
assertTrue(response?.subagents?.isEmpty() == true)
}
@Test
fun tailSubagentSendsExactlySessionIdAndSubagentId() {
// #1379: the contract forbids extra keys (max_bytes) and requires session_id.
val captured = slot<Map<String, Any>>()
val captured = slot<SubagentTailParams>()
mockkObject(HermesWsClient)
every { HermesWsClient.request(WsMethods.SUBAGENT_TAIL, capture(captured), any(), any()) } returns
CompletableDeferred<Any?>(mapOf("subagent_id" to "sub-1"))
coEvery { HermesWsClient.call(RpcMethods.SUBAGENT_TAIL, capture(captured), any(), true) } returns
buildJsonObject { put("subagent_id", "sub-1") }
runBlocking { SubagentRepository.tailSubagent("s1", "sub-1") }
assertEquals(mapOf<String, Any>("session_id" to "s1", "subagent_id" to "sub-1"), captured.captured)
assertEquals(SubagentTailParams("s1", "sub-1"), captured.captured)
}
@Test
@@ -44,7 +62,7 @@ class SubagentRepositoryTest {
assertNull(SubagentRepository.tailSubagent("", "sub-1"))
assertNull(SubagentRepository.tailSubagent("s1", " "))
}
verify(exactly = 0) { HermesWsClient.request(any(), any(), any(), any()) }
coVerify(exactly = 0) { HermesWsClient.call(RpcMethods.SUBAGENT_TAIL, any(), any(), any()) }
}
@Test
@@ -0,0 +1,44 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonElement
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 SubagentTypesTest {
@Test
fun listEncodesOnlySessionId() {
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionIdParams.serializer(), SessionIdParams("s1"))
assertEquals(buildJsonObject { put("session_id", "s1") }, encoded)
}
@Test
fun tailEncodesExactlyTheTwoRequiredIds() {
val encoded =
OkHttpProvider.json.encodeToJsonElement(
SubagentTailParams.serializer(),
SubagentTailParams("s1", "sub-1"),
)
assertEquals(
buildJsonObject {
put("session_id", "s1")
put("subagent_id", "sub-1")
},
encoded,
)
}
@Test
fun descriptorsAreRegisteredAndPreserveRawResults() {
assertTrue(RpcMethods.SUBAGENT_LIST in RpcMethods.all)
assertTrue(RpcMethods.SUBAGENT_TAIL in RpcMethods.all)
assertEquals("subagent.list", RpcMethods.SUBAGENT_LIST.name)
assertEquals("subagent.tail", RpcMethods.SUBAGENT_TAIL.name)
val raw: JsonElement = buildJsonObject { put("future_field", "retained") }
assertEquals(raw, OkHttpProvider.json.decodeFromJsonElement(RpcMethods.SUBAGENT_LIST.result, raw))
assertEquals(raw, OkHttpProvider.json.decodeFromJsonElement(RpcMethods.SUBAGENT_TAIL.result, raw))
}
}