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>(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.str(key: String): String? = (this[key] as? JsonPrimitive)?.content ?: this[key] as? String