refactor(#1375): typed params for session.create, session.resume, prompt.submit (#1381)

This commit is contained in:
m57 authored and GitHub committed 2026-09-29 16:31:03 +03:00
1 parent d466414563
commit a780df3261
9 files changed
+942 -90

No files matched your search

@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.await
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethod
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
@@ -827,8 +828,10 @@ object HermesWsClient {
/**
* Send a typed JSON-RPC request and await its deserialized result.
*
* Params classes are strict writers, result classes are tolerant readers, and
* profile is never a field in params (it is injected by WsProfileParams).
* Params classes are strict writers; `profile` is a field ONLY on methods
* where callers pass an explicit override (e.g. session.create, session.resume);
* otherwise it is injected by WsProfileParams (an explicit value wins).
* Result classes are tolerant readers.
*/
suspend fun <P, R> call(
method: RpcMethod<P, R>,
@@ -836,10 +839,7 @@ object HermesWsClient {
timeoutMs: Long = REQUEST_TIMEOUT_MS,
suppressErrorEvent: Boolean = false,
): R {
val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params)
require(encoded is JsonObject) {
"RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}"
}
val encoded = encodeParams(method, params)
val deferred = request(method.name, encoded, timeoutMs, suppressErrorEvent)
val result =
try {
@@ -852,6 +852,17 @@ object HermesWsClient {
return OkHttpProvider.json.decodeFromJsonElement(method.result, element)
}
private fun <P, R> encodeParams(
method: RpcMethod<P, R>,
params: P,
): JsonObject {
val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params)
require(encoded is JsonObject) {
"RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}"
}
return encoded
}
/** Complete (or fail) a single pending call and cancel its timer. */
private fun resolvePending(
id: String,
@@ -957,6 +968,21 @@ object HermesWsClient {
onSent: ((String) -> Unit)? = null,
): String = send(method, params, onSent, true)
/**
* Send a typed fire-and-forget JSON-RPC request.
*
* Params classes are strict writers; `profile` is a field ONLY on methods
* where callers pass an explicit override (session.create, session.resume);
* otherwise it is injected by WsProfileParams (an explicit value wins).
*
* @return the request id used (can be matched against [WsEvent.RpcResult]).
*/
fun <P, R> send(
method: RpcMethod<P, R>,
params: P,
onSent: ((String) -> Unit)? = null,
): String = send(method.name, encodeParams(method, params), onSent)
private fun send(
method: String,
params: Map<String, Any>,
@@ -1065,23 +1091,17 @@ object HermesWsClient {
text: String,
onSent: ((String) -> Unit)? = null,
queued: Boolean = false,
): String {
val params =
buildMap {
put("session_id", sessionId)
put("text", text)
// Explicit queue semantics: the gateway's busy-input policy
// forces "run after, never interrupt" when prompt.submit
// carries queued=true (hermes-agent methods_prompt.py
// _handle_busy_submit) — used by /queue.
if (queued) put("queued", true)
}
return send(
method = WsMethods.PROMPT_SUBMIT,
params = params,
): String =
send(
method = RpcMethods.PROMPT_SUBMIT,
params =
PromptSubmitParams(
sessionId = sessionId,
text = text,
queued = queued.takeIf { it },
),
onSent = onSent,
)
}
/**
* Convenience: redirect the active model turn while it is still generating
@@ -17,6 +17,33 @@ object RpcMethods {
SessionEventsSinceResult.serializer(),
)
val SESSION_CREATE: RpcMethod<SessionCreateParams, SessionCreateResult> =
RpcMethod(
WsMethods.SESSION_CREATE,
SessionCreateParams.serializer(),
SessionCreateResult.serializer(),
)
val SESSION_RESUME: RpcMethod<SessionResumeParams, SessionResumeResult> =
RpcMethod(
WsMethods.SESSION_RESUME,
SessionResumeParams.serializer(),
SessionResumeResult.serializer(),
)
val PROMPT_SUBMIT: RpcMethod<PromptSubmitParams, PromptSubmitResult> =
RpcMethod(
WsMethods.PROMPT_SUBMIT,
PromptSubmitParams.serializer(),
PromptSubmitResult.serializer(),
)
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */
val all: List<RpcMethod<*, *>> = listOf(SESSION_EVENTS_SINCE)
val all: List<RpcMethod<*, *>> =
listOf(
SESSION_EVENTS_SINCE,
SESSION_CREATE,
SESSION_RESUME,
PROMPT_SUBMIT,
)
}
@@ -0,0 +1,43 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SessionCreateParams(
@SerialName("source") val source: String? = null,
@SerialName("profile") val profile: String? = null,
@SerialName("title") val title: String? = null,
@SerialName("hidden") val hidden: Boolean? = null,
)
@Serializable
data class SessionResumeParams(
@SerialName("session_id") val sessionId: String,
@SerialName("omit_messages") val omitMessages: Boolean? = null,
@SerialName("profile") val profile: String? = null,
)
@Serializable
data class PromptSubmitParams(
@SerialName("session_id") val sessionId: String,
@SerialName("text") val text: String,
@SerialName("queued") val queued: Boolean? = null,
)
// tolerant readers: all nullable with null defaults, only fields callers read
@Serializable
data class SessionCreateResult(
@SerialName("session_id") val sessionId: String? = null,
@SerialName("stored_session_id") val storedSessionId: String? = null,
)
@Serializable
data class SessionResumeResult(
@SerialName("session_id") val sessionId: String? = null,
)
@Serializable
data class PromptSubmitResult(
val status: String? = null,
)
@@ -11,7 +11,9 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -109,12 +111,11 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
val runtimeSessionId =
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
?: resumeSession(sessionId)
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeSessionId, "text" to replyText),
timeoutMs = REPLY_TIMEOUT_MS,
).await()
HermesWsClient.call(
method = RpcMethods.PROMPT_SUBMIT,
params = PromptSubmitParams(sessionId = runtimeSessionId, text = replyText),
timeoutMs = REPLY_TIMEOUT_MS,
)
val entity =
com.m57.hermescontrol.data.local.ChatMessageEntity(
@@ -152,24 +153,21 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
}
private suspend fun resumeSession(storedSessionId: String): String {
val profile = AuthManager.activeProfileId.value
val profile = AuthManager.activeProfileId.value?.takeIf { it.isNotBlank() }
val params =
mutableMapOf<String, Any>(
"session_id" to storedSessionId,
"omit_messages" to true,
SessionResumeParams(
sessionId = storedSessionId,
omitMessages = true,
profile = profile,
)
if (!profile.isNullOrBlank()) {
params["profile"] = profile
}
val result =
HermesWsClient
.request(
WsMethods.SESSION_RESUME,
params,
timeoutMs = REPLY_TIMEOUT_MS,
).await() as? Map<*, *>
HermesWsClient.call(
method = RpcMethods.SESSION_RESUME,
params = params,
timeoutMs = REPLY_TIMEOUT_MS,
)
val runtimeSessionId =
(result?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
result.sessionId?.takeIf { it.isNotBlank() }
?: error("Resume returned no runtime session id")
ActiveSessionHolder.set(runtimeSessionId, storedSessionId)
return runtimeSessionId
@@ -17,6 +17,9 @@ 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.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionCreateParams
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.chat.tool.ToolResultSummary
import kotlinx.coroutines.CompletableDeferred
@@ -559,17 +562,24 @@ class GroupChatViewModel(
val title = "Group: $groupName"
try {
val createParams =
buildMap<String, Any> {
put("profile", bot.name)
put("title", title)
put("source", "desktop")
put("hidden", true)
}
val deferred = HermesWsClient.request(WsMethods.SESSION_CREATE, createParams)
val res = deferred.await()
val sessionInfo = extractSessionInfo(res)
if (sessionInfo != null) {
val res =
HermesWsClient.call(
method = RpcMethods.SESSION_CREATE,
params =
SessionCreateParams(
source = "desktop",
profile = bot.name,
title = title,
hidden = true,
),
)
val runtimeId = res.sessionId?.takeIf { it.isNotBlank() }
if (runtimeId != null) {
val sessionInfo =
MemberSession(
runtimeSessionId = runtimeId,
storedSessionId = res.storedSessionId,
)
memberSessions[bot.name] = sessionInfo
return sessionInfo
}
@@ -701,11 +711,10 @@ class GroupChatViewModel(
// turn boundary can be proven for them. They stay uncorrelated
// (reply notifications from group turns are never auto-dismissed
// from REST hydration) instead of borrowing another turn's bound.
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeId, "text" to prompt),
).await()
HermesWsClient.call(
method = RpcMethods.PROMPT_SUBMIT,
params = PromptSubmitParams(sessionId = runtimeId, text = prompt),
)
} catch (submitErr: Exception) {
Log.w(
"GroupChatViewModel",
@@ -735,11 +744,10 @@ class GroupChatViewModel(
storedId?.let { inFlightTurns[it] = turnDeferred }
try {
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeId, "text" to prompt),
).await()
HermesWsClient.call(
method = RpcMethods.PROMPT_SUBMIT,
params = PromptSubmitParams(sessionId = runtimeId, text = prompt),
)
} catch (retryErr: Exception) {
Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}")
return null
@@ -1202,19 +1210,4 @@ class GroupChatViewModel(
val clean = text.trim().lowercase()
return clean == "(pass)" || clean == "pass" || clean == "(pass)." || clean == "pass."
}
private fun extractSessionInfo(response: Any?): MemberSession? {
if (response is Map<*, *>) {
val runtimeId =
response["session_id"]?.toString()?.trim('\"', ' ')
?: (response["result"] as? Map<*, *>)?.get("session_id")?.toString()?.trim('\"', ' ')
val storedId =
response["stored_session_id"]?.toString()?.trim('\"', ' ')
?: (response["result"] as? Map<*, *>)?.get("stored_session_id")?.toString()?.trim('\"', ' ')
if (!runtimeId.isNullOrBlank()) {
return MemberSession(runtimeSessionId = runtimeId, storedSessionId = storedId)
}
}
return null
}
}
@@ -0,0 +1,484 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionCreateParams
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
class HermesWsClientTypedSendTest {
private lateinit var mockWebServer: MockWebServer
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
AuthManager.resetAuthStateForTest()
}
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
HermesWsClient.connect()
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
}
@Test
fun typedSendSessionResumePutsExpectedFrameAndInvokesOnSent() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.resume") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
var onSentId: String? = null
val returnedId =
HermesWsClient.send(
method = RpcMethods.SESSION_RESUME,
params = SessionResumeParams(sessionId = "sess-1", omitMessages = true),
onSent = { id -> onSentId = id },
)
assertTrue(returnedId.isNotBlank())
assertEquals(returnedId, onSentId)
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("session.resume", (frame["method"] as? JsonPrimitive)?.content)
assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "sess-1")
put("omit_messages", true)
}
assertEquals(expectedParams, params)
}
@Test
fun typedSendWithActiveProfileInjectsProfileIntoSessionResume() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.resume") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
AuthManager.setActiveProfileId("active-profile-1")
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
HermesWsClient.send(
method = RpcMethods.SESSION_RESUME,
params = SessionResumeParams(sessionId = "sess-active", omitMessages = true),
)
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "sess-active")
put("omit_messages", true)
put("profile", "active-profile-1")
}
assertEquals(expectedParams, params)
assertEquals("active-profile-1", (params!!["profile"] as? JsonPrimitive)?.content)
}
@Test
fun typedSendExplicitProfileWinsOverActiveProfileForSessionResume() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.resume") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
AuthManager.setActiveProfileId("active-profile-1")
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
HermesWsClient.send(
method = RpcMethods.SESSION_RESUME,
params =
SessionResumeParams(
sessionId = "sess-active",
omitMessages = true,
profile = "explicit-resume-profile",
),
)
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "sess-active")
put("omit_messages", true)
put("profile", "explicit-resume-profile")
}
assertEquals(expectedParams, params)
assertEquals("explicit-resume-profile", (params!!["profile"] as? JsonPrimitive)?.content)
}
@Test
fun sessionCreateWithExplicitProfileKeepsExplicitWhenDifferentProfileActive() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.create") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
AuthManager.setActiveProfileId("active-profile-2")
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
HermesWsClient.send(
method = RpcMethods.SESSION_CREATE,
params = SessionCreateParams(profile = "bot-1"),
)
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("profile", "bot-1")
}
assertEquals(expectedParams, params)
assertEquals("bot-1", (params!!["profile"] as? JsonPrimitive)?.content)
}
@Test
fun sendMessagePutsSessionIdAndTextOnTheWire() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "prompt.submit") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
var onSentId: String? = null
val returnedId =
HermesWsClient.sendMessage(
sessionId = "sess-msg-1",
text = "plain message",
onSent = { id -> onSentId = id },
)
assertTrue(returnedId.isNotBlank())
assertEquals(returnedId, onSentId)
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "sess-msg-1")
put("text", "plain message")
}
assertEquals(expectedParams, params)
assertFalse(params!!.containsKey("queued"))
}
@Test
fun sendMessageWithQueuedTrueAddsQueuedTrue() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "prompt.submit") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
HermesWsClient.sendMessage(
sessionId = "sess-msg-2",
text = "queued message",
queued = true,
)
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "sess-msg-2")
put("text", "queued message")
put("queued", true)
}
assertEquals(expectedParams, params)
val queuedElement = params!!["queued"] as? JsonPrimitive
assertTrue(queuedElement != null && queuedElement.isString.not())
assertEquals("true", queuedElement!!.content)
}
@Test
fun sendMessageWithQueuedFalseHasNoQueuedKey() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "prompt.submit") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
HermesWsClient.sendMessage(
sessionId = "sess-msg-3",
text = "un为其queued message",
queued = false,
)
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "sess-msg-3")
put("text", "un为其queued message")
}
assertEquals(expectedParams, params)
assertFalse(params!!.containsKey("queued"))
}
}
@@ -334,7 +334,6 @@ class RawRpcParamsRatchetTest {
"data/ws/ConnectorRepository.kt::CONNECTORS_CONNECT",
"data/ws/ConnectorRepository.kt::CONNECTORS_LIST",
"data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES",
"data/ws/HermesWsClient.kt::PROMPT_SUBMIT",
"data/ws/HermesWsClient.kt::SESSION_REDIRECT",
"data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS",
"data/ws/PluginManageRepository.kt::PLUGINS_MANAGE",
@@ -349,8 +348,6 @@ class RawRpcParamsRatchetTest {
"data/ws/VaultRepository.kt::VAULT_SOURCES",
"data/ws/VaultRepository.kt::VAULT_SOURCE_SET",
"data/ws/VaultRepository.kt::VAULT_UNLOCK",
"notification/NotificationReplyReceiver.kt::PROMPT_SUBMIT",
"notification/NotificationReplyReceiver.kt::SESSION_RESUME",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
@@ -359,9 +356,6 @@ class RawRpcParamsRatchetTest {
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/group/GroupChatViewModel.kt::PROFILES_LIST",
"ui/bots/group/GroupChatViewModel.kt::PROMPT_SUBMIT",
"ui/bots/group/GroupChatViewModel.kt::PROMPT_SUBMIT",
"ui/bots/group/GroupChatViewModel.kt::SESSION_CREATE",
"ui/bots/group/GroupChatViewModel.kt::SESSION_INTERRUPT",
"ui/chat/ChatApprovalsDelegate.kt::APPROVAL_PENDING",
"ui/chat/ChatApprovalsDelegate.kt::APPROVAL_RECEIVED",
@@ -0,0 +1,290 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionPromptTypesTest {
@Test
fun createWithSourceOnlyEncodesExactlySource() {
val params = SessionCreateParams(source = "cli")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
val expected =
buildJsonObject {
put("source", "cli")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("source"), (encoded as JsonObject).keys)
}
@Test
fun groupStyleCreateEncodesExpectedFourKeysWithHiddenBoolean() {
val params =
SessionCreateParams(
profile = "work",
title = "Team sync",
source = "matrix",
hidden = true,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
val expected =
buildJsonObject {
put("source", "matrix")
put("profile", "work")
put("title", "Team sync")
put("hidden", true)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
val hiddenElement = (encoded as JsonObject)["hidden"] as? JsonPrimitive
assertTrue(hiddenElement != null && hiddenElement.isString.not())
}
@Test
fun allNullCreateParamsEncodeToEmptyObject() {
val params = SessionCreateParams()
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
val expected = buildJsonObject {}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertTrue((encoded as JsonObject).isEmpty())
}
@Test
fun createWithExplicitFalseHiddenIsEncoded() {
val params = SessionCreateParams(hidden = false)
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
val expected =
buildJsonObject {
put("hidden", false)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("hidden"), (encoded as JsonObject).keys)
}
@Test
fun resumeEncodesSessionIdAndOmitMessagesExactly() {
val params = SessionResumeParams(sessionId = "s-123", omitMessages = true)
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s-123")
put("omit_messages", true)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "omit_messages"), (encoded as JsonObject).keys)
}
@Test
fun resumeWithProfileEncodesExpectedThreeKeys() {
val params =
SessionResumeParams(
sessionId = "s-456",
omitMessages = true,
profile = "personal",
)
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s-456")
put("omit_messages", true)
put("profile", "personal")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "omit_messages", "profile"), (encoded as JsonObject).keys)
}
@Test
fun resumeWithExplicitFalseOmitMessagesIsEncoded() {
val params = SessionResumeParams(sessionId = "s-789", omitMessages = false)
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s-789")
put("omit_messages", false)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "omit_messages"), (encoded as JsonObject).keys)
}
@Test
fun submitEncodesSessionIdAndText() {
val params = PromptSubmitParams(sessionId = "s-100", text = "hello hermes")
val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s-100")
put("text", "hello hermes")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys)
}
@Test
fun submitWithQueuedTrueEncodesQueued() {
val params = PromptSubmitParams(sessionId = "s-101", text = "queued prompt", queued = true)
val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s-101")
put("text", "queued prompt")
put("queued", true)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "text", "queued"), (encoded as JsonObject).keys)
}
@Test
fun submitWithExplicitFalseQueuedIsEncoded() {
val params = PromptSubmitParams(sessionId = "s-102", text = "direct prompt", queued = false)
val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s-102")
put("text", "direct prompt")
put("queued", false)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "text", "queued"), (encoded as JsonObject).keys)
}
@Test
fun sessionCreateResultDecodesFullPayloadAndIgnoresUnknownKeys() {
val payload =
buildJsonObject {
put("session_id", "sess-created-1")
put("stored_session_id", "stored-1")
put("created_at", 123456789)
put("unknown_flag", true)
}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionCreateResult.serializer(),
payload,
)
assertEquals("sess-created-1", decoded.sessionId)
assertEquals("stored-1", decoded.storedSessionId)
}
@Test
fun sessionCreateResultDecodesEmptyObjectToNulls() {
val payload = buildJsonObject {}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionCreateResult.serializer(),
payload,
)
assertNull(decoded.sessionId)
assertNull(decoded.storedSessionId)
}
@Test
fun sessionResumeResultDecodesFullPayloadAndIgnoresUnknownKeys() {
val payload =
buildJsonObject {
put("session_id", "sess-resumed-1")
put("active_model", "gemini")
put("unread_count", 0)
}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionResumeResult.serializer(),
payload,
)
assertEquals("sess-resumed-1", decoded.sessionId)
}
@Test
fun sessionResumeResultDecodesEmptyObjectToNulls() {
val payload = buildJsonObject {}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionResumeResult.serializer(),
payload,
)
assertNull(decoded.sessionId)
}
@Test
fun promptSubmitResultDecodesFullPayloadAndIgnoresUnknownKeys() {
val payload =
buildJsonObject {
put("status", "accepted")
put("ticket_id", "tick-42")
put("queued_at", "2026-09-29T10:00:00Z")
}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
PromptSubmitResult.serializer(),
payload,
)
assertEquals("accepted", decoded.status)
}
@Test
fun promptSubmitResultDecodesEmptyObjectToNulls() {
val payload = buildJsonObject {}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
PromptSubmitResult.serializer(),
payload,
)
assertNull(decoded.status)
}
@Test
fun rpcMethodsRegistrationMatchesContract() {
assertEquals("session.create", RpcMethods.SESSION_CREATE.name)
assertEquals("session.resume", RpcMethods.SESSION_RESUME.name)
assertEquals("prompt.submit", RpcMethods.PROMPT_SUBMIT.name)
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_CREATE))
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_RESUME))
assertTrue(RpcMethods.all.contains(RpcMethods.PROMPT_SUBMIT))
}
}
@@ -21,6 +21,7 @@ import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.serialization.json.JsonPrimitive
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -108,7 +109,7 @@ class NotificationReplyReceiverTest {
val params = arg<Map<String, Any>>(1)
val result =
if (method == WsMethods.SESSION_RESUME) {
mapOf("session_id" to "runtime-${params["session_id"]}")
mapOf("session_id" to "runtime-${params.str("session_id")}")
} else {
mapOf("accepted" to true)
}
@@ -147,7 +148,7 @@ class NotificationReplyReceiverTest {
verify {
HermesWsClient.request(
WsMethods.PROMPT_SUBMIT,
match { it["session_id"] == "runtime-session-abc" && it["text"] == "Hello" },
match { it.str("session_id") == "runtime-session-abc" && it.str("text") == "Hello" },
any(),
)
}
@@ -329,19 +330,19 @@ class NotificationReplyReceiverTest {
verify {
HermesWsClient.request(
WsMethods.SESSION_RESUME,
match { it["session_id"] == "session-abc" && it["omit_messages"] == true },
match { it.str("session_id") == "session-abc" && it["omit_messages"] == JsonPrimitive(true) },
any(),
)
HermesWsClient.request(
WsMethods.PROMPT_SUBMIT,
match { it["session_id"] == "runtime-session-abc" },
match { it.str("session_id") == "runtime-session-abc" },
any(),
)
}
verify(inverse = true) {
HermesWsClient.request(
WsMethods.PROMPT_SUBMIT,
match { it["session_id"] == "stale-runtime" },
match { it.str("session_id") == "stale-runtime" },
any(),
)
}
@@ -454,3 +455,5 @@ class NotificationReplyReceiverTest {
every { mockIntent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) } returns sessionId
}
}
private fun Map<String, Any>.str(key: String): String? = (this[key] as? JsonPrimitive)?.content ?: this[key] as? String