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 e89ba820..5b6c329d 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 @@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws.contract import com.m57.hermescontrol.data.ws.WsMethods import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.JsonElement class RpcMethod( val name: String, @@ -59,6 +60,56 @@ object RpcMethods { SessionCorrectionResult.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val SESSION_LIST: RpcMethod = + RpcMethod( + WsMethods.SESSION_LIST, + SessionListParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_BRANCH: RpcMethod = + RpcMethod( + WsMethods.SESSION_BRANCH, + SessionBranchParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_BRANCH_WHOLE: RpcMethod = + RpcMethod( + WsMethods.SESSION_BRANCH_WHOLE, + SessionBranchWholeParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_COMPRESS: RpcMethod = + RpcMethod( + WsMethods.SESSION_COMPRESS, + SessionCompressParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_CONTEXT_BREAKDOWN: RpcMethod = + RpcMethod( + WsMethods.SESSION_CONTEXT_BREAKDOWN, + SessionIdParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_USAGE: RpcMethod = + RpcMethod( + WsMethods.SESSION_USAGE, + SessionIdParams.serializer(), + JsonElement.serializer(), + ) + + val PROMPT_BTW: RpcMethod = + RpcMethod( + WsMethods.PROMPT_BTW, + PromptBtwParams.serializer(), + PromptBtwResult.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -69,5 +120,12 @@ object RpcMethods { SESSION_INTERRUPT, SESSION_STEER, SESSION_REDIRECT, + SESSION_LIST, + SESSION_BRANCH, + SESSION_BRANCH_WHOLE, + SESSION_COMPRESS, + SESSION_CONTEXT_BREAKDOWN, + SESSION_USAGE, + PROMPT_BTW, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionMisc.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionMisc.kt new file mode 100644 index 00000000..54b7e59d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionMisc.kt @@ -0,0 +1,41 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data object SessionListParams + +@Serializable +data class SessionIdParams( + @SerialName("session_id") val sessionId: String, +) + +@Serializable +data class SessionBranchParams( + @SerialName("session_id") val sessionId: String, + @SerialName("name") val name: String? = null, +) + +@Serializable +data class SessionBranchWholeParams( + @SerialName("session_id") val sessionId: String, + @SerialName("name") val name: String? = null, +) + +@Serializable +data class SessionCompressParams( + @SerialName("session_id") val sessionId: String, + @SerialName("focus_topic") val focusTopic: String? = null, +) + +@Serializable +data class PromptBtwParams( + @SerialName("session_id") val sessionId: String, + @SerialName("text") val text: String, +) + +@Serializable +data class PromptBtwResult( + @SerialName("task_id") val taskId: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 1f9aac39..91eb50aa 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -37,9 +37,17 @@ import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.JsonRpcError import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.PromptBtwParams import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionBranchParams +import com.m57.hermescontrol.data.ws.contract.SessionBranchWholeParams +import com.m57.hermescontrol.data.ws.contract.SessionCompressParams import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams +import com.m57.hermescontrol.data.ws.contract.SessionCreateParams +import com.m57.hermescontrol.data.ws.contract.SessionIdParams import com.m57.hermescontrol.data.ws.contract.SessionInterruptParams +import com.m57.hermescontrol.data.ws.contract.SessionListParams +import com.m57.hermescontrol.data.ws.contract.SessionResumeParams import com.m57.hermescontrol.data.ws.toAny import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.notification.ReplyNotificationTracker @@ -509,7 +517,7 @@ class ChatViewModel( private data class PendingBranchRequest( val generation: Long, - val params: Map, + val params: SessionBranchWholeParams, ) private val sessionRequestById = ConcurrentHashMap() @@ -2225,12 +2233,12 @@ class ChatViewModel( (error as? JsonRpcError)?.code ?: (error as? Map<*, *>)?.get("code") as? Int if (code == -32601 && pendingBranch != null && pendingBranch.generation == sessionGeneration) { - val params = pendingBranch.params + val p = pendingBranch.params val generation = pendingBranch.generation viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_BRANCH, - params, + RpcMethods.SESSION_BRANCH, + SessionBranchParams(sessionId = p.sessionId, name = p.name), onSent = { branchId -> trackSessionRequest(branchId, WsMethods.SESSION_BRANCH, generation) }, @@ -3578,18 +3586,6 @@ class ChatViewModel( private fun attachmentTooLargeMessage(attachment: Attachment): String = "Attachment too large: ${attachment.name} (maximum 10 MB)" - /** - * Send a JSON-RPC call and suspend until the response arrives, delegating - * the deferred + 120s timeout to [HermesWsClient.request] (issue #526). - * Throws [HermesWsClient.HermesRpcException] on RPC error, or - * [kotlinx.coroutines.TimeoutCancellationException] if the server never - * answers within the timeout. - */ - private suspend fun sendRpcAndAwait( - method: String, - params: Map, - ): Any? = HermesWsClient.request(method, params).await() - // ── Attachment management ───────────────────────────────────────────── /** @@ -3942,13 +3938,12 @@ class ChatViewModel( viewModelScope.launch(ioDispatcher) { try { - val rpcResult = + val taskId = wsClient - .request( - WsMethods.PROMPT_BTW, - mapOf("session_id" to sessionId, "text" to trimmed), - ).await() - val taskId = (rpcResult as? Map<*, *>)?.get("task_id") as? String + .call( + RpcMethods.PROMPT_BTW, + PromptBtwParams(sessionId = sessionId, text = trimmed), + ).taskId if (!taskId.isNullOrBlank()) { _uiState.update { state -> state.btwState?.let { current -> @@ -4034,12 +4029,11 @@ class ChatViewModel( return } val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() - val params = mutableMapOf("session_id" to sessionId) - if (arg.isNotBlank()) params["name"] = arg + val params = SessionBranchWholeParams(sessionId = sessionId, name = arg.takeIf { it.isNotBlank() }) val generation = sessionGeneration viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_BRANCH_WHOLE, + RpcMethods.SESSION_BRANCH_WHOLE, params, onSent = { id -> branchWholeRequests[id] = PendingBranchRequest(generation, params) @@ -4197,21 +4191,16 @@ class ChatViewModel( viewModelScope.launch(ioDispatcher) { try { - val params = - buildMap { - put("session_id", sessionId) - if (focusTopic.isNotBlank()) { - put("focus_topic", focusTopic) - } - } // Use extended timeout (e.g. 300_000L / 5 minutes) so LLM summarization doesn't timeout val result = - wsClient - .request( - WsMethods.SESSION_COMPRESS, - params, - timeoutMs = 300_000L, - ).await() + wsClient.call( + RpcMethods.SESSION_COMPRESS, + SessionCompressParams( + sessionId = sessionId, + focusTopic = focusTopic.takeIf { it.isNotBlank() }, + ), + timeoutMs = 300_000L, + ) handleCompressionResult(result) } catch (e: HermesWsClient.HermesRpcException) { @@ -4439,8 +4428,8 @@ class ChatViewModel( val generation = resetSessionState(sessionId = null, title = "Hermes", isLoading = setLoading) viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_CREATE, - params = mapOf("source" to "desktop"), + RpcMethods.SESSION_CREATE, + SessionCreateParams(source = "desktop"), onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) }, ) } @@ -4460,7 +4449,8 @@ class ChatViewModel( fun loadSessions() { viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_LIST, + RpcMethods.SESSION_LIST, + SessionListParams, onSent = { id -> trackRequest(id, WsMethods.SESSION_LIST) }, ) } @@ -5254,16 +5244,14 @@ class ChatViewModel( val connectionCheckpoint = connectionOperationDelegate.resumeCheckpoint() val profile = AuthManager.activeProfileId.value val params = - mutableMapOf( - "session_id" to sessionId, - "omit_messages" to true, + SessionResumeParams( + sessionId = sessionId, + omitMessages = true, + profile = profile?.takeIf { it.isNotBlank() }, ) - if (!profile.isNullOrBlank()) { - params["profile"] = profile - } viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_RESUME, + RpcMethods.SESSION_RESUME, params, onSent = { id -> trackSessionRequest( @@ -5755,9 +5743,9 @@ class ChatViewModel( if (rpcSessionId != null) { try { val result = - sendRpcAndAwait( - WsMethods.SESSION_CONTEXT_BREAKDOWN, - mapOf("session_id" to rpcSessionId), + wsClient.call( + RpcMethods.SESSION_CONTEXT_BREAKDOWN, + SessionIdParams(rpcSessionId), ) coroutineContext.ensureActive() val ctx = parseContextBreakdown(result) @@ -5794,9 +5782,9 @@ class ChatViewModel( if (!isSwitchPending) { try { val usage = - sendRpcAndAwait( - WsMethods.SESSION_USAGE, - mapOf("session_id" to rpcSessionId), + wsClient.call( + RpcMethods.SESSION_USAGE, + SessionIdParams(rpcSessionId), ) coroutineContext.ensureActive() val snapshot = parseUsageSnapshot(usage) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionMiscTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionMiscTest.kt new file mode 100644 index 00000000..edd2a00e --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionMiscTest.kt @@ -0,0 +1,584 @@ +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.PromptBtwParams +import com.m57.hermescontrol.data.ws.contract.PromptBtwResult +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionBranchParams +import com.m57.hermescontrol.data.ws.contract.SessionBranchWholeParams +import com.m57.hermescontrol.data.ws.contract.SessionCompressParams +import com.m57.hermescontrol.data.ws.contract.SessionIdParams +import com.m57.hermescontrol.data.ws.contract.SessionListParams +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.JsonElement +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 HermesWsClientSessionMiscTest { + 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 typedSendSessionListPutsEmptyParamsOnWireWithoutActiveProfile() { + 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.list") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.send( + method = RpcMethods.SESSION_LIST, + params = SessionListParams, + 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.list", (frame["method"] as? JsonPrimitive)?.content) + assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content) + + // An empty params object is omitted on the wire (encodeDefaults=false), same as before the migration. + val params = frame["params"] as? JsonObject + assertTrue("session.list carries no params", params == null || params.isEmpty()) + } + + @Test + fun typedSendSessionListWithActiveProfileInjectsProfileOnWire() { + 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.list") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + AuthManager.setActiveProfileId("active-profile-sessions") + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + HermesWsClient.send( + method = RpcMethods.SESSION_LIST, + params = SessionListParams, + ) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("profile", "active-profile-sessions") + } + assertEquals(expectedParams, params) + } + + @Test + fun typedCallPromptBtwRoundTripsTaskIdAndReturnsPromptBtwResult() { + 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.btw") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":{"task_id":"task-async-123","status":"queued"}}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: PromptBtwResult = + runBlocking { + HermesWsClient.call( + RpcMethods.PROMPT_BTW, + PromptBtwParams(sessionId = "sess-btw-call", text = "remember this detail"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("prompt.btw", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-btw-call") + put("text", "remember this detail") + } + assertEquals(expectedParams, frame["params"]) + assertEquals("task-async-123", result.taskId) + } + + @Test + fun typedCallSessionUsageReturnsRawResultJsonElementUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("prompt_tokens", 1024) + put("completion_tokens", 256) + put("total_tokens", 1280) + put("cost_usd", 0.015) + put( + "nested_stats", + buildJsonObject { + put("cache_read_tokens", 512) + put("reasoning_tokens", 64) + }, + ) + } + + 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.usage") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_USAGE, + SessionIdParams(sessionId = "s1"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.usage", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "s1") + } + assertEquals(expectedParams, frame["params"]) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallSessionCompressWithCustomTimeoutPutsExactWirePayload() { + 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.compress") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":{"status":"compressed"}}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + method = RpcMethods.SESSION_COMPRESS, + params = SessionCompressParams(sessionId = "sess-long-comp", focusTopic = "deep-dive"), + timeoutMs = 300_000L, + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.compress", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-long-comp") + put("focus_topic", "deep-dive") + } + assertEquals(expectedParams, frame["params"]) + + val expectedResult = + buildJsonObject { + put("status", "compressed") + } + assertEquals(expectedResult, result) + } + + @Test + fun typedCallSessionBranchPutsExactWirePayloadAndDecodesPassthroughResult() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResult = + buildJsonObject { + put("new_session_id", "sess-branched-99") + } + + 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.branch") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResult}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_BRANCH, + SessionBranchParams(sessionId = "sess-orig-1", name = "branch-name"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.branch", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-orig-1") + put("name", "branch-name") + } + assertEquals(expectedParams, frame["params"]) + assertEquals(serverResult, result) + } + + @Test + fun typedCallSessionBranchWholePutsExactWirePayloadAndDecodesPassthroughResult() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResult = + buildJsonObject { + put("whole_branch_session_id", "sess-whole-branched-101") + } + + 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.branch_whole") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResult}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_BRANCH_WHOLE, + SessionBranchWholeParams(sessionId = "sess-whole-orig-1", name = null), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.branch_whole", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-whole-orig-1") + } + assertEquals(expectedParams, frame["params"]) + assertEquals(serverResult, result) + } + + @Test + fun typedCallSessionContextBreakdownPutsExactWirePayloadAndDecodesPassthroughResult() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResult = + buildJsonObject { + put("system_prompt", 450) + put("messages", 2300) + put("tools", 800) + } + + 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.context_breakdown") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResult}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_CONTEXT_BREAKDOWN, + SessionIdParams(sessionId = "sess-bd-1"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.context_breakdown", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-bd-1") + } + assertEquals(expectedParams, frame["params"]) + assertEquals(serverResult, result) + } +} 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 ea8f658d..37c39ff9 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 @@ -372,15 +372,6 @@ class RawRpcParamsRatchetTest { "ui/chat/ChatViewModel.kt::CONFIG_SET", "ui/chat/ChatViewModel.kt::FILE_ATTACH", "ui/chat/ChatViewModel.kt::IMAGE_ATTACH_BYTES", - "ui/chat/ChatViewModel.kt::PROMPT_BTW", - "ui/chat/ChatViewModel.kt::SESSION_BRANCH", - "ui/chat/ChatViewModel.kt::SESSION_BRANCH_WHOLE", - "ui/chat/ChatViewModel.kt::SESSION_COMPRESS", - "ui/chat/ChatViewModel.kt::SESSION_CONTEXT_BREAKDOWN", - "ui/chat/ChatViewModel.kt::SESSION_CREATE", - "ui/chat/ChatViewModel.kt::SESSION_LIST", - "ui/chat/ChatViewModel.kt::SESSION_RESUME", - "ui/chat/ChatViewModel.kt::SESSION_USAGE", "ui/chat/ChatViewModel.kt::SLASH_EXEC", "ui/process/ProcessesViewModel.kt::PROCESS_KILL", "ui/process/ProcessesViewModel.kt::PROCESS_LIST", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionMiscTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionMiscTypesTest.kt new file mode 100644 index 00000000..f32892a1 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionMiscTypesTest.kt @@ -0,0 +1,295 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +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 SessionMiscTypesTest { + @Test + fun sessionListParamsEncodesToEmptyJsonObject() { + val params = SessionListParams + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionListParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun sessionIdParamsEncodesSessionIdExactly() { + val params = SessionIdParams(sessionId = "sess-misc-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionIdParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-misc-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchParamsEncodesSessionIdOnlyWhenNameNull() { + val params = SessionBranchParams(sessionId = "sess-branch-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-branch-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchParamsEncodesSessionIdAndNameWhenPresent() { + val params = SessionBranchParams(sessionId = "sess-branch-2", name = "experiment-fork") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-branch-2") + put("name", "experiment-fork") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchParamsPassesBlankNameThroughAsIs() { + val params = SessionBranchParams(sessionId = "sess-branch-3", name = " ") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-branch-3") + put("name", " ") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchWholeParamsEncodesSessionIdOnlyWhenNameNull() { + val params = SessionBranchWholeParams(sessionId = "sess-whole-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-whole-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchWholeParamsEncodesSessionIdAndNameWhenPresent() { + val params = SessionBranchWholeParams(sessionId = "sess-whole-2", name = "full-tree-fork") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-whole-2") + put("name", "full-tree-fork") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchWholeParamsPassesBlankNameThroughAsIs() { + val params = SessionBranchWholeParams(sessionId = "sess-whole-3", name = " \t ") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-whole-3") + put("name", " \t ") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys) + } + + @Test + fun sessionCompressParamsEncodesSessionIdOnlyWhenFocusTopicNull() { + val params = SessionCompressParams(sessionId = "sess-comp-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-comp-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun sessionCompressParamsEncodesSessionIdAndFocusTopicWhenPresent() { + val params = SessionCompressParams(sessionId = "sess-comp-2", focusTopic = "summarize-db-schema") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-comp-2") + put("focus_topic", "summarize-db-schema") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "focus_topic"), (encoded as JsonObject).keys) + } + + @Test + fun sessionCompressParamsPassesBlankFocusTopicThroughAsIs() { + val params = SessionCompressParams(sessionId = "sess-comp-3", focusTopic = " ") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-comp-3") + put("focus_topic", " ") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "focus_topic"), (encoded as JsonObject).keys) + } + + @Test + fun promptBtwParamsEncodesSessionIdAndText() { + val params = PromptBtwParams(sessionId = "sess-btw-1", text = "by the way, take note") + val encoded = OkHttpProvider.json.encodeToJsonElement(PromptBtwParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-btw-1") + put("text", "by the way, take note") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys) + } + + @Test + fun promptBtwResultDecodesFullPayloadAndIgnoresUnknownKeys() { + val payload = + buildJsonObject { + put("task_id", "task-xyz-987") + put("unknown_flag", true) + put("extra_num", 100) + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + PromptBtwResult.serializer(), + payload, + ) + + assertEquals("task-xyz-987", decoded.taskId) + } + + @Test + fun promptBtwResultDecodesEmptyObjectToNulls() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + PromptBtwResult.serializer(), + payload, + ) + + assertNull(decoded.taskId) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("session.list", RpcMethods.SESSION_LIST.name) + assertEquals("session.branch", RpcMethods.SESSION_BRANCH.name) + assertEquals("session.branch_whole", RpcMethods.SESSION_BRANCH_WHOLE.name) + assertEquals("session.compress", RpcMethods.SESSION_COMPRESS.name) + assertEquals("session.context_breakdown", RpcMethods.SESSION_CONTEXT_BREAKDOWN.name) + assertEquals("session.usage", RpcMethods.SESSION_USAGE.name) + assertEquals("prompt.btw", RpcMethods.PROMPT_BTW.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_LIST)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_BRANCH)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_BRANCH_WHOLE)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_COMPRESS)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_CONTEXT_BREAKDOWN)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_USAGE)) + assertTrue(RpcMethods.all.contains(RpcMethods.PROMPT_BTW)) + } + + @Test + fun jsonElementPassthroughMethodsDecodeResultUntouched() { + val arbitraryPayload = + buildJsonObject { + put("status", "ok") + put("tokens", 4567) + put("nested", buildJsonObject { put("item", "val") }) + } + + val decodedList: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_LIST.result, + arbitraryPayload, + ) + val decodedBranch: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_BRANCH.result, + arbitraryPayload, + ) + val decodedBranchWhole: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_BRANCH_WHOLE.result, + arbitraryPayload, + ) + val decodedCompress: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_COMPRESS.result, + arbitraryPayload, + ) + val decodedBreakdown: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_CONTEXT_BREAKDOWN.result, + arbitraryPayload, + ) + val decodedUsage: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_USAGE.result, + arbitraryPayload, + ) + + assertEquals(arbitraryPayload, decodedList) + assertEquals(arbitraryPayload, decodedBranch) + assertEquals(arbitraryPayload, decodedBranchWhole) + assertEquals(arbitraryPayload, decodedCompress) + assertEquals(arbitraryPayload, decodedBreakdown) + assertEquals(arbitraryPayload, decodedUsage) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt index daed040b..3e0294ab 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt @@ -201,7 +201,7 @@ class ChatCompressRpcTest { val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) val params = paramsCalls[compressIndex] - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic")) } @@ -224,8 +224,8 @@ class ChatCompressRpcTest { val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) val params = paramsCalls[compressIndex] - assertEquals(sessionId, params["session_id"]) - assertEquals("auth decisions", params["focus_topic"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) + assertEquals(JsonPrimitive("auth decisions"), params["focus_topic"]) } @Test @@ -247,7 +247,7 @@ class ChatCompressRpcTest { val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) val params = paramsCalls[compressIndex] - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic")) assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls) } @@ -271,8 +271,8 @@ class ChatCompressRpcTest { val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) val params = paramsCalls[compressIndex] - assertEquals(sessionId, params["session_id"]) - assertEquals("architecture details", params["focus_topic"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) + assertEquals(JsonPrimitive("architecture details"), params["focus_topic"]) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 99e121a5..e69f29a0 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -63,6 +63,7 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject @@ -767,8 +768,8 @@ class ChatViewModelTest { val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } assertNotNull("session.branch_whole should be dispatched for /fork", branchSent) - assertEquals(sessionId, branchSent!!.second["session_id"]) - assertEquals("my-fork", branchSent.second["name"]) + assertEquals(JsonPrimitive(sessionId), branchSent!!.second["session_id"]) + assertEquals(JsonPrimitive("my-fork"), branchSent.second["name"]) } @Test @@ -813,7 +814,7 @@ class ChatViewModelTest { val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } assertNotNull("session.branch_whole should be dispatched for /fork", branchSent) - assertEquals(sessionId, branchSent!!.second["session_id"]) + assertEquals(JsonPrimitive(sessionId), branchSent!!.second["session_id"]) assertFalse("name param should be omitted when no title given", branchSent.second.containsKey("name")) } @@ -876,8 +877,8 @@ class ChatViewModelTest { val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } assertNotNull("session.branch_whole should be sent", branchWholeSent) - assertEquals("my-retry-branch", branchWholeSent!!.second["name"]) - assertEquals(sessionId, branchWholeSent.second["session_id"]) + assertEquals(JsonPrimitive("my-retry-branch"), branchWholeSent!!.second["name"]) + assertEquals(JsonPrimitive(sessionId), branchWholeSent.second["session_id"]) // Backend answers -32601 unknown method mockEventsFlow.emit( @@ -891,8 +892,8 @@ class ChatViewModelTest { // Retries ONCE with session.branch using same params val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH } assertNotNull("session.branch fallback should be sent", branchSent) - assertEquals("my-retry-branch", branchSent!!.second["name"]) - assertEquals(sessionId, branchSent.second["session_id"]) + assertEquals(JsonPrimitive("my-retry-branch"), branchSent!!.second["name"]) + assertEquals(JsonPrimitive(sessionId), branchSent.second["session_id"]) assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH }) // session.branch result is then handled @@ -1077,7 +1078,12 @@ class ChatViewModelTest { verify { HermesWsClient.request( WsMethods.PROMPT_BTW, - mapOf("session_id" to sessionId, "text" to "which file was that in?"), + JsonObject( + mapOf( + "session_id" to JsonPrimitive(sessionId), + "text" to JsonPrimitive("which file was that in?"), + ), + ), any(), ) } @@ -1573,7 +1579,12 @@ class ChatViewModelTest { verify { HermesWsClient.send( WsMethods.SESSION_RESUME, - mapOf("session_id" to "session-from-notification", "omit_messages" to true), + JsonObject( + mapOf( + "session_id" to JsonPrimitive("session-from-notification"), + "omit_messages" to JsonPrimitive(true), + ), + ), any(), ) } @@ -4983,7 +4994,12 @@ class ChatViewModelTest { verify { HermesWsClient.send( WsMethods.SESSION_RESUME, - mapOf("session_id" to "session-456", "omit_messages" to true), + JsonObject( + mapOf( + "session_id" to JsonPrimitive("session-456"), + "omit_messages" to JsonPrimitive(true), + ), + ), any(), ) } @@ -4996,7 +5012,7 @@ class ChatViewModelTest { val (viewModel, _) = createViewModelWithSession() val resumeRequests = mutableMapOf() every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers { - val sessionId = arg>(1).getValue("session_id") + val sessionId = (arg>(1).getValue("session_id") as JsonPrimitive).content val requestId = "resume-$sessionId" resumeRequests[sessionId] = requestId arg<((String) -> Unit)?>(2)?.invoke(requestId) @@ -5090,7 +5106,7 @@ class ChatViewModelTest { val (viewModel, _) = createViewModelWithSession() val resumeRequests = mutableMapOf() every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers { - val sessionId = arg>(1).getValue("session_id") + val sessionId = (arg>(1).getValue("session_id") as JsonPrimitive).content val requestId = "resume-$sessionId" resumeRequests[sessionId] = requestId arg<((String) -> Unit)?>(2)?.invoke(requestId) @@ -5689,14 +5705,14 @@ class ChatViewModelTest { verify(exactly = 0) { HermesWsClient.request( WsMethods.SESSION_CONTEXT_BREAKDOWN, - match { it["session_id"] == "session-456" }, + match { it["session_id"] == JsonPrimitive("session-456") }, any(), ) } verify(exactly = 0) { HermesWsClient.request( WsMethods.SESSION_USAGE, - match { it["session_id"] == "session-456" }, + match { it["session_id"] == JsonPrimitive("session-456") }, any(), ) } @@ -5718,18 +5734,18 @@ class ChatViewModelTest { verify { HermesWsClient.request( WsMethods.SESSION_CONTEXT_BREAKDOWN, - match { it["session_id"] == "runtime-456" }, + match { it["session_id"] == JsonPrimitive("runtime-456") }, any(), ) } verify { HermesWsClient.request( WsMethods.SESSION_USAGE, - match { it["session_id"] == "runtime-456" }, + match { it["session_id"] == JsonPrimitive("runtime-456") }, any(), ) } - assertEquals("runtime-456", paramsSlot.captured["session_id"]) + assertEquals(JsonPrimitive("runtime-456"), paramsSlot.captured["session_id"]) } @Test @@ -5833,7 +5849,12 @@ class ChatViewModelTest { verify { HermesWsClient.send( WsMethods.SESSION_RESUME, - mapOf("session_id" to "session-456", "omit_messages" to true), + JsonObject( + mapOf( + "session_id" to JsonPrimitive("session-456"), + "omit_messages" to JsonPrimitive(true), + ), + ), any(), ) } @@ -9476,7 +9497,7 @@ class ChatViewModelTest { any(), ) } - assertEquals("session-stored-999", resumeParamsSlot.captured["session_id"]) + assertEquals(JsonPrimitive("session-stored-999"), resumeParamsSlot.captured["session_id"]) } @Test