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
+1066 -88

No files matched your search

@@ -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<P, R>(
val name: String,
@@ -59,6 +60,56 @@ object RpcMethods {
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. */
val all: List<RpcMethod<*, *>> =
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,
)
}
@@ -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.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<String, Any>,
val params: SessionBranchWholeParams,
)
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
@@ -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<String, Any>,
): 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<String, Any>("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<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
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<String, Any>(
"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)
@@ -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::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",
@@ -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)
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
@@ -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<String, String>()
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"
resumeRequests[sessionId] = requestId
arg<((String) -> Unit)?>(2)?.invoke(requestId)
@@ -5090,7 +5106,7 @@ class ChatViewModelTest {
val (viewModel, _) = createViewModelWithSession()
val resumeRequests = mutableMapOf<String, String>()
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"
resumeRequests[sessionId] = requestId
arg<((String) -> Unit)?>(2)?.invoke(requestId)
@@ -5689,14 +5705,14 @@ class ChatViewModelTest {
verify(exactly = 0) {
HermesWsClient.request(
WsMethods.SESSION_CONTEXT_BREAKDOWN,
match { it["session_id"] == "session-456" },
match { it["session_id"] == JsonPrimitive("session-456") },
any(),
)
}
verify(exactly = 0) {
HermesWsClient.request(
WsMethods.SESSION_USAGE,
match { it["session_id"] == "session-456" },
match { it["session_id"] == JsonPrimitive("session-456") },
any(),
)
}
@@ -5718,18 +5734,18 @@ class ChatViewModelTest {
verify {
HermesWsClient.request(
WsMethods.SESSION_CONTEXT_BREAKDOWN,
match { it["session_id"] == "runtime-456" },
match { it["session_id"] == JsonPrimitive("runtime-456") },
any(),
)
}
verify {
HermesWsClient.request(
WsMethods.SESSION_USAGE,
match { it["session_id"] == "runtime-456" },
match { it["session_id"] == JsonPrimitive("runtime-456") },
any(),
)
}
assertEquals("runtime-456", paramsSlot.captured["session_id"])
assertEquals(JsonPrimitive("runtime-456"), paramsSlot.captured["session_id"])
}
@Test
@@ -5833,7 +5849,12 @@ class ChatViewModelTest {
verify {
HermesWsClient.send(
WsMethods.SESSION_RESUME,
mapOf("session_id" to "session-456", "omit_messages" to true),
JsonObject(
mapOf(
"session_id" to JsonPrimitive("session-456"),
"omit_messages" to JsonPrimitive(true),
),
),
any(),
)
}
@@ -9476,7 +9497,7 @@ class ChatViewModelTest {
any(),
)
}
assertEquals("session-stored-999", resumeParamsSlot.captured["session_id"])
assertEquals(JsonPrimitive("session-stored-999"), resumeParamsSlot.captured["session_id"])
}
@Test