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

@@ -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