This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user