From c5b34ff4713cf54c4cea50a49fcdf18a7171c0a5 Mon Sep 17 00:00:00 2001
From: m57 <41645758+Hy4ri@users.noreply.github.com>
Date: Tue, 29 Sep 2026 18:09:45 +0300
Subject: [PATCH] refactor(#1375): typed params for remaining ChatViewModel
session.* and prompt.btw (#1384)
---
.../data/ws/contract/RpcMethod.kt | 58 ++
.../data/ws/contract/SessionMisc.kt | 41 ++
.../hermescontrol/ui/chat/ChatViewModel.kt | 96 ++-
.../data/ws/HermesWsClientSessionMiscTest.kt | 584 ++++++++++++++++++
.../data/ws/RawRpcParamsRatchetTest.kt | 9 -
.../data/ws/contract/SessionMiscTypesTest.kt | 295 +++++++++
.../ui/chat/ChatCompressRpcTest.kt | 12 +-
.../ui/chat/ChatViewModelTest.kt | 59 +-
8 files changed, 1066 insertions(+), 88 deletions(-)
create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionMisc.kt
create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionMiscTest.kt
create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionMiscTypesTest.kt
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