diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt
index acc2c633..21ebec99 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt
@@ -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
call(
method: RpcMethod
,
@@ -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
encodeParams(
+ method: RpcMethod
,
+ 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
send(
+ method: RpcMethod
,
+ params: P,
+ onSent: ((String) -> Unit)? = null,
+ ): String = send(method.name, encodeParams(method, params), onSent)
+
private fun send(
method: String,
params: Map,
@@ -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
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 4288387d..ef0a134b 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
@@ -17,6 +17,33 @@ object RpcMethods {
SessionEventsSinceResult.serializer(),
)
+ val SESSION_CREATE: RpcMethod =
+ RpcMethod(
+ WsMethods.SESSION_CREATE,
+ SessionCreateParams.serializer(),
+ SessionCreateResult.serializer(),
+ )
+
+ val SESSION_RESUME: RpcMethod =
+ RpcMethod(
+ WsMethods.SESSION_RESUME,
+ SessionResumeParams.serializer(),
+ SessionResumeResult.serializer(),
+ )
+
+ val PROMPT_SUBMIT: RpcMethod =
+ RpcMethod(
+ WsMethods.PROMPT_SUBMIT,
+ PromptSubmitParams.serializer(),
+ PromptSubmitResult.serializer(),
+ )
+
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */
- val all: List> = listOf(SESSION_EVENTS_SINCE)
+ val all: List> =
+ listOf(
+ SESSION_EVENTS_SINCE,
+ SESSION_CREATE,
+ SESSION_RESUME,
+ PROMPT_SUBMIT,
+ )
}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionPrompt.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionPrompt.kt
new file mode 100644
index 00000000..42dcbd03
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionPrompt.kt
@@ -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,
+)
diff --git a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt
index 42867446..c72a073e 100644
--- a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt
+++ b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt
@@ -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(
- "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
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 8d6e7fd5..6cc4ba96 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
@@ -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 {
- 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
- }
}
diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedSendTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedSendTest.kt
new file mode 100644
index 00000000..655e174d
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedSendTest.kt
@@ -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(), any()) } returns 0
+ every { Log.i(any(), any()) } returns 0
+ every { Log.w(any(), any()) } returns 0
+ every { Log.w(any(), any(), any()) } returns 0
+ every { Log.e(any(), any(), 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().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(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(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(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(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(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(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(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"))
+ }
+}
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 7f103fc2..5751feef 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
@@ -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",
diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionPromptTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionPromptTypesTest.kt
new file mode 100644
index 00000000..bf5c9c44
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionPromptTypesTest.kt
@@ -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))
+ }
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt
index fd0953b9..e9b0aaa3 100644
--- a/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt
@@ -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