refactor(#1375): typed params for remaining ChatViewModel session.* and prompt.btw (#1384)

This commit is contained in:
m57 authored and GitHub committed 2026-09-29 18:09:45 +03:00
1 parent 1dd8d2138d
commit c5b34ff471
8 files changed
+1065 -87

No files matched your search

@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.JsonElement
class RpcMethod<P, R>( class RpcMethod<P, R>(
val name: String, val name: String,
@@ -59,6 +60,56 @@ object RpcMethods {
SessionCorrectionResult.serializer(), SessionCorrectionResult.serializer(),
) )
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val SESSION_LIST: RpcMethod<SessionListParams, JsonElement> =
RpcMethod(
WsMethods.SESSION_LIST,
SessionListParams.serializer(),
JsonElement.serializer(),
)
val SESSION_BRANCH: RpcMethod<SessionBranchParams, JsonElement> =
RpcMethod(
WsMethods.SESSION_BRANCH,
SessionBranchParams.serializer(),
JsonElement.serializer(),
)
val SESSION_BRANCH_WHOLE: RpcMethod<SessionBranchWholeParams, JsonElement> =
RpcMethod(
WsMethods.SESSION_BRANCH_WHOLE,
SessionBranchWholeParams.serializer(),
JsonElement.serializer(),
)
val SESSION_COMPRESS: RpcMethod<SessionCompressParams, JsonElement> =
RpcMethod(
WsMethods.SESSION_COMPRESS,
SessionCompressParams.serializer(),
JsonElement.serializer(),
)
val SESSION_CONTEXT_BREAKDOWN: RpcMethod<SessionIdParams, JsonElement> =
RpcMethod(
WsMethods.SESSION_CONTEXT_BREAKDOWN,
SessionIdParams.serializer(),
JsonElement.serializer(),
)
val SESSION_USAGE: RpcMethod<SessionIdParams, JsonElement> =
RpcMethod(
WsMethods.SESSION_USAGE,
SessionIdParams.serializer(),
JsonElement.serializer(),
)
val PROMPT_BTW: RpcMethod<PromptBtwParams, PromptBtwResult> =
RpcMethod(
WsMethods.PROMPT_BTW,
PromptBtwParams.serializer(),
PromptBtwResult.serializer(),
)
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */ /** Every typed method. GatewayContractTest iterates this; future migrations append here. */
val all: List<RpcMethod<*, *>> = val all: List<RpcMethod<*, *>> =
listOf( listOf(
@@ -69,5 +120,12 @@ object RpcMethods {
SESSION_INTERRUPT, SESSION_INTERRUPT,
SESSION_STEER, SESSION_STEER,
SESSION_REDIRECT, SESSION_REDIRECT,
SESSION_LIST,
SESSION_BRANCH,
SESSION_BRANCH_WHOLE,
SESSION_COMPRESS,
SESSION_CONTEXT_BREAKDOWN,
SESSION_USAGE,
PROMPT_BTW,
) )
} }
@@ -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,
)
@@ -37,9 +37,17 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.JsonRpcError import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods 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.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.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.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.toAny
import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.notification.ReplyNotificationTracker import com.m57.hermescontrol.notification.ReplyNotificationTracker
@@ -509,7 +517,7 @@ class ChatViewModel(
private data class PendingBranchRequest( private data class PendingBranchRequest(
val generation: Long, val generation: Long,
val params: Map<String, Any>, val params: SessionBranchWholeParams,
) )
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>() private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
@@ -2225,12 +2233,12 @@ class ChatViewModel(
(error as? JsonRpcError)?.code (error as? JsonRpcError)?.code
?: (error as? Map<*, *>)?.get("code") as? Int ?: (error as? Map<*, *>)?.get("code") as? Int
if (code == -32601 && pendingBranch != null && pendingBranch.generation == sessionGeneration) { if (code == -32601 && pendingBranch != null && pendingBranch.generation == sessionGeneration) {
val params = pendingBranch.params val p = pendingBranch.params
val generation = pendingBranch.generation val generation = pendingBranch.generation
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
wsClient.send( wsClient.send(
WsMethods.SESSION_BRANCH, RpcMethods.SESSION_BRANCH,
params, SessionBranchParams(sessionId = p.sessionId, name = p.name),
onSent = { branchId -> onSent = { branchId ->
trackSessionRequest(branchId, WsMethods.SESSION_BRANCH, generation) trackSessionRequest(branchId, WsMethods.SESSION_BRANCH, generation)
}, },
@@ -3578,18 +3586,6 @@ class ChatViewModel(
private fun attachmentTooLargeMessage(attachment: Attachment): String = private fun attachmentTooLargeMessage(attachment: Attachment): String =
"Attachment too large: ${attachment.name} (maximum 10 MB)" "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<String, Any>,
): Any? = HermesWsClient.request(method, params).await()
// ── Attachment management ───────────────────────────────────────────── // ── Attachment management ─────────────────────────────────────────────
/** /**
@@ -3942,13 +3938,12 @@ class ChatViewModel(
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
try { try {
val rpcResult = val taskId =
wsClient wsClient
.request( .call(
WsMethods.PROMPT_BTW, RpcMethods.PROMPT_BTW,
mapOf("session_id" to sessionId, "text" to trimmed), PromptBtwParams(sessionId = sessionId, text = trimmed),
).await() ).taskId
val taskId = (rpcResult as? Map<*, *>)?.get("task_id") as? String
if (!taskId.isNullOrBlank()) { if (!taskId.isNullOrBlank()) {
_uiState.update { state -> _uiState.update { state ->
state.btwState?.let { current -> state.btwState?.let { current ->
@@ -4034,12 +4029,11 @@ class ChatViewModel(
return return
} }
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
val params = mutableMapOf<String, Any>("session_id" to sessionId) val params = SessionBranchWholeParams(sessionId = sessionId, name = arg.takeIf { it.isNotBlank() })
if (arg.isNotBlank()) params["name"] = arg
val generation = sessionGeneration val generation = sessionGeneration
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
wsClient.send( wsClient.send(
WsMethods.SESSION_BRANCH_WHOLE, RpcMethods.SESSION_BRANCH_WHOLE,
params, params,
onSent = { id -> onSent = { id ->
branchWholeRequests[id] = PendingBranchRequest(generation, params) branchWholeRequests[id] = PendingBranchRequest(generation, params)
@@ -4197,21 +4191,16 @@ class ChatViewModel(
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
try { try {
val params =
buildMap<String, Any> {
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 // Use extended timeout (e.g. 300_000L / 5 minutes) so LLM summarization doesn't timeout
val result = val result =
wsClient wsClient.call(
.request( RpcMethods.SESSION_COMPRESS,
WsMethods.SESSION_COMPRESS, SessionCompressParams(
params, sessionId = sessionId,
focusTopic = focusTopic.takeIf { it.isNotBlank() },
),
timeoutMs = 300_000L, timeoutMs = 300_000L,
).await() )
handleCompressionResult(result) handleCompressionResult(result)
} catch (e: HermesWsClient.HermesRpcException) { } catch (e: HermesWsClient.HermesRpcException) {
@@ -4439,8 +4428,8 @@ class ChatViewModel(
val generation = resetSessionState(sessionId = null, title = "Hermes", isLoading = setLoading) val generation = resetSessionState(sessionId = null, title = "Hermes", isLoading = setLoading)
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
wsClient.send( wsClient.send(
WsMethods.SESSION_CREATE, RpcMethods.SESSION_CREATE,
params = mapOf("source" to "desktop"), SessionCreateParams(source = "desktop"),
onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) }, onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) },
) )
} }
@@ -4460,7 +4449,8 @@ class ChatViewModel(
fun loadSessions() { fun loadSessions() {
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
wsClient.send( wsClient.send(
WsMethods.SESSION_LIST, RpcMethods.SESSION_LIST,
SessionListParams,
onSent = { id -> trackRequest(id, WsMethods.SESSION_LIST) }, onSent = { id -> trackRequest(id, WsMethods.SESSION_LIST) },
) )
} }
@@ -5254,16 +5244,14 @@ class ChatViewModel(
val connectionCheckpoint = connectionOperationDelegate.resumeCheckpoint() val connectionCheckpoint = connectionOperationDelegate.resumeCheckpoint()
val profile = AuthManager.activeProfileId.value val profile = AuthManager.activeProfileId.value
val params = val params =
mutableMapOf<String, Any>( SessionResumeParams(
"session_id" to sessionId, sessionId = sessionId,
"omit_messages" to true, omitMessages = true,
profile = profile?.takeIf { it.isNotBlank() },
) )
if (!profile.isNullOrBlank()) {
params["profile"] = profile
}
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
wsClient.send( wsClient.send(
WsMethods.SESSION_RESUME, RpcMethods.SESSION_RESUME,
params, params,
onSent = { id -> onSent = { id ->
trackSessionRequest( trackSessionRequest(
@@ -5755,9 +5743,9 @@ class ChatViewModel(
if (rpcSessionId != null) { if (rpcSessionId != null) {
try { try {
val result = val result =
sendRpcAndAwait( wsClient.call(
WsMethods.SESSION_CONTEXT_BREAKDOWN, RpcMethods.SESSION_CONTEXT_BREAKDOWN,
mapOf("session_id" to rpcSessionId), SessionIdParams(rpcSessionId),
) )
coroutineContext.ensureActive() coroutineContext.ensureActive()
val ctx = parseContextBreakdown(result) val ctx = parseContextBreakdown(result)
@@ -5794,9 +5782,9 @@ class ChatViewModel(
if (!isSwitchPending) { if (!isSwitchPending) {
try { try {
val usage = val usage =
sendRpcAndAwait( wsClient.call(
WsMethods.SESSION_USAGE, RpcMethods.SESSION_USAGE,
mapOf("session_id" to rpcSessionId), SessionIdParams(rpcSessionId),
) )
coroutineContext.ensureActive() coroutineContext.ensureActive()
val snapshot = parseUsageSnapshot(usage) val snapshot = parseUsageSnapshot(usage)
@@ -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<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
AuthManager.resetAuthStateForTest()
}
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
HermesWsClient.connect()
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
}
@Test
fun typedSendSessionListPutsEmptyParamsOnWireWithoutActiveProfile() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.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<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.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<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "prompt.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<JsonObject?>(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<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.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<JsonObject?>(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<JsonObject?>(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<JsonObject?>(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)
}
}
@@ -372,15 +372,6 @@ class RawRpcParamsRatchetTest {
"ui/chat/ChatViewModel.kt::CONFIG_SET", "ui/chat/ChatViewModel.kt::CONFIG_SET",
"ui/chat/ChatViewModel.kt::FILE_ATTACH", "ui/chat/ChatViewModel.kt::FILE_ATTACH",
"ui/chat/ChatViewModel.kt::IMAGE_ATTACH_BYTES", "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/chat/ChatViewModel.kt::SLASH_EXEC",
"ui/process/ProcessesViewModel.kt::PROCESS_KILL", "ui/process/ProcessesViewModel.kt::PROCESS_KILL",
"ui/process/ProcessesViewModel.kt::PROCESS_LIST", "ui/process/ProcessesViewModel.kt::PROCESS_LIST",
@@ -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)
}
}
@@ -201,7 +201,7 @@ class ChatCompressRpcTest {
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
val params = paramsCalls[compressIndex] 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")) 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) val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
val params = paramsCalls[compressIndex] val params = paramsCalls[compressIndex]
assertEquals(sessionId, params["session_id"]) assertEquals(JsonPrimitive(sessionId), params["session_id"])
assertEquals("auth decisions", params["focus_topic"]) assertEquals(JsonPrimitive("auth decisions"), params["focus_topic"])
} }
@Test @Test
@@ -247,7 +247,7 @@ class ChatCompressRpcTest {
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
val params = paramsCalls[compressIndex] 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")) assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic"))
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls) assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
} }
@@ -271,8 +271,8 @@ class ChatCompressRpcTest {
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
val params = paramsCalls[compressIndex] val params = paramsCalls[compressIndex]
assertEquals(sessionId, params["session_id"]) assertEquals(JsonPrimitive(sessionId), params["session_id"])
assertEquals("architecture details", params["focus_topic"]) assertEquals(JsonPrimitive("architecture details"), params["focus_topic"])
} }
@Test @Test
@@ -63,6 +63,7 @@ import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
@@ -767,8 +768,8 @@ class ChatViewModelTest {
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
assertNotNull("session.branch_whole should be dispatched for /fork", branchSent) assertNotNull("session.branch_whole should be dispatched for /fork", branchSent)
assertEquals(sessionId, branchSent!!.second["session_id"]) assertEquals(JsonPrimitive(sessionId), branchSent!!.second["session_id"])
assertEquals("my-fork", branchSent.second["name"]) assertEquals(JsonPrimitive("my-fork"), branchSent.second["name"])
} }
@Test @Test
@@ -813,7 +814,7 @@ class ChatViewModelTest {
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
assertNotNull("session.branch_whole should be dispatched for /fork", branchSent) 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")) 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 } val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
assertNotNull("session.branch_whole should be sent", branchWholeSent) assertNotNull("session.branch_whole should be sent", branchWholeSent)
assertEquals("my-retry-branch", branchWholeSent!!.second["name"]) assertEquals(JsonPrimitive("my-retry-branch"), branchWholeSent!!.second["name"])
assertEquals(sessionId, branchWholeSent.second["session_id"]) assertEquals(JsonPrimitive(sessionId), branchWholeSent.second["session_id"])
// Backend answers -32601 unknown method // Backend answers -32601 unknown method
mockEventsFlow.emit( mockEventsFlow.emit(
@@ -891,8 +892,8 @@ class ChatViewModelTest {
// Retries ONCE with session.branch using same params // Retries ONCE with session.branch using same params
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH } val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH }
assertNotNull("session.branch fallback should be sent", branchSent) assertNotNull("session.branch fallback should be sent", branchSent)
assertEquals("my-retry-branch", branchSent!!.second["name"]) assertEquals(JsonPrimitive("my-retry-branch"), branchSent!!.second["name"])
assertEquals(sessionId, branchSent.second["session_id"]) assertEquals(JsonPrimitive(sessionId), branchSent.second["session_id"])
assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH }) assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH })
// session.branch result is then handled // session.branch result is then handled
@@ -1077,7 +1078,12 @@ class ChatViewModelTest {
verify { verify {
HermesWsClient.request( HermesWsClient.request(
WsMethods.PROMPT_BTW, 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(), any(),
) )
} }
@@ -1573,7 +1579,12 @@ class ChatViewModelTest {
verify { verify {
HermesWsClient.send( HermesWsClient.send(
WsMethods.SESSION_RESUME, 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(), any(),
) )
} }
@@ -4983,7 +4994,12 @@ class ChatViewModelTest {
verify { verify {
HermesWsClient.send( HermesWsClient.send(
WsMethods.SESSION_RESUME, 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(), any(),
) )
} }
@@ -4996,7 +5012,7 @@ class ChatViewModelTest {
val (viewModel, _) = createViewModelWithSession() val (viewModel, _) = createViewModelWithSession()
val resumeRequests = mutableMapOf<String, String>() val resumeRequests = mutableMapOf<String, String>()
every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers { every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers {
val sessionId = arg<Map<String, String>>(1).getValue("session_id") val sessionId = (arg<Map<String, Any?>>(1).getValue("session_id") as JsonPrimitive).content
val requestId = "resume-$sessionId" val requestId = "resume-$sessionId"
resumeRequests[sessionId] = requestId resumeRequests[sessionId] = requestId
arg<((String) -> Unit)?>(2)?.invoke(requestId) arg<((String) -> Unit)?>(2)?.invoke(requestId)
@@ -5090,7 +5106,7 @@ class ChatViewModelTest {
val (viewModel, _) = createViewModelWithSession() val (viewModel, _) = createViewModelWithSession()
val resumeRequests = mutableMapOf<String, String>() val resumeRequests = mutableMapOf<String, String>()
every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers { every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers {
val sessionId = arg<Map<String, String>>(1).getValue("session_id") val sessionId = (arg<Map<String, Any?>>(1).getValue("session_id") as JsonPrimitive).content
val requestId = "resume-$sessionId" val requestId = "resume-$sessionId"
resumeRequests[sessionId] = requestId resumeRequests[sessionId] = requestId
arg<((String) -> Unit)?>(2)?.invoke(requestId) arg<((String) -> Unit)?>(2)?.invoke(requestId)
@@ -5689,14 +5705,14 @@ class ChatViewModelTest {
verify(exactly = 0) { verify(exactly = 0) {
HermesWsClient.request( HermesWsClient.request(
WsMethods.SESSION_CONTEXT_BREAKDOWN, WsMethods.SESSION_CONTEXT_BREAKDOWN,
match { it["session_id"] == "session-456" }, match { it["session_id"] == JsonPrimitive("session-456") },
any(), any(),
) )
} }
verify(exactly = 0) { verify(exactly = 0) {
HermesWsClient.request( HermesWsClient.request(
WsMethods.SESSION_USAGE, WsMethods.SESSION_USAGE,
match { it["session_id"] == "session-456" }, match { it["session_id"] == JsonPrimitive("session-456") },
any(), any(),
) )
} }
@@ -5718,18 +5734,18 @@ class ChatViewModelTest {
verify { verify {
HermesWsClient.request( HermesWsClient.request(
WsMethods.SESSION_CONTEXT_BREAKDOWN, WsMethods.SESSION_CONTEXT_BREAKDOWN,
match { it["session_id"] == "runtime-456" }, match { it["session_id"] == JsonPrimitive("runtime-456") },
any(), any(),
) )
} }
verify { verify {
HermesWsClient.request( HermesWsClient.request(
WsMethods.SESSION_USAGE, WsMethods.SESSION_USAGE,
match { it["session_id"] == "runtime-456" }, match { it["session_id"] == JsonPrimitive("runtime-456") },
any(), any(),
) )
} }
assertEquals("runtime-456", paramsSlot.captured["session_id"]) assertEquals(JsonPrimitive("runtime-456"), paramsSlot.captured["session_id"])
} }
@Test @Test
@@ -5833,7 +5849,12 @@ class ChatViewModelTest {
verify { verify {
HermesWsClient.send( HermesWsClient.send(
WsMethods.SESSION_RESUME, 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(), any(),
) )
} }
@@ -9476,7 +9497,7 @@ class ChatViewModelTest {
any(), any(),
) )
} }
assertEquals("session-stored-999", resumeParamsSlot.captured["session_id"]) assertEquals(JsonPrimitive("session-stored-999"), resumeParamsSlot.captured["session_id"])
} }
@Test @Test