This commit is contained in:
9 files changed
+942
-90
No files matched your search
@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
|
|||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.data.remote.await
|
import com.m57.hermescontrol.data.remote.await
|
||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
||||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
||||||
@@ -827,8 +828,10 @@ object HermesWsClient {
|
|||||||
/**
|
/**
|
||||||
* Send a typed JSON-RPC request and await its deserialized result.
|
* Send a typed JSON-RPC request and await its deserialized result.
|
||||||
*
|
*
|
||||||
* Params classes are strict writers, result classes are tolerant readers, and
|
* Params classes are strict writers; `profile` is a field ONLY on methods
|
||||||
* profile is never a field in params (it is injected by WsProfileParams).
|
* where callers pass an explicit override (e.g. session.create, session.resume);
|
||||||
|
* otherwise it is injected by WsProfileParams (an explicit value wins).
|
||||||
|
* Result classes are tolerant readers.
|
||||||
*/
|
*/
|
||||||
suspend fun <P, R> call(
|
suspend fun <P, R> call(
|
||||||
method: RpcMethod<P, R>,
|
method: RpcMethod<P, R>,
|
||||||
@@ -836,10 +839,7 @@ object HermesWsClient {
|
|||||||
timeoutMs: Long = REQUEST_TIMEOUT_MS,
|
timeoutMs: Long = REQUEST_TIMEOUT_MS,
|
||||||
suppressErrorEvent: Boolean = false,
|
suppressErrorEvent: Boolean = false,
|
||||||
): R {
|
): R {
|
||||||
val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params)
|
val encoded = encodeParams(method, params)
|
||||||
require(encoded is JsonObject) {
|
|
||||||
"RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}"
|
|
||||||
}
|
|
||||||
val deferred = request(method.name, encoded, timeoutMs, suppressErrorEvent)
|
val deferred = request(method.name, encoded, timeoutMs, suppressErrorEvent)
|
||||||
val result =
|
val result =
|
||||||
try {
|
try {
|
||||||
@@ -852,6 +852,17 @@ object HermesWsClient {
|
|||||||
return OkHttpProvider.json.decodeFromJsonElement(method.result, element)
|
return OkHttpProvider.json.decodeFromJsonElement(method.result, element)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun <P, R> encodeParams(
|
||||||
|
method: RpcMethod<P, R>,
|
||||||
|
params: P,
|
||||||
|
): JsonObject {
|
||||||
|
val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params)
|
||||||
|
require(encoded is JsonObject) {
|
||||||
|
"RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}"
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
/** Complete (or fail) a single pending call and cancel its timer. */
|
/** Complete (or fail) a single pending call and cancel its timer. */
|
||||||
private fun resolvePending(
|
private fun resolvePending(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -957,6 +968,21 @@ object HermesWsClient {
|
|||||||
onSent: ((String) -> Unit)? = null,
|
onSent: ((String) -> Unit)? = null,
|
||||||
): String = send(method, params, onSent, true)
|
): String = send(method, params, onSent, true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a typed fire-and-forget JSON-RPC request.
|
||||||
|
*
|
||||||
|
* Params classes are strict writers; `profile` is a field ONLY on methods
|
||||||
|
* where callers pass an explicit override (session.create, session.resume);
|
||||||
|
* otherwise it is injected by WsProfileParams (an explicit value wins).
|
||||||
|
*
|
||||||
|
* @return the request id used (can be matched against [WsEvent.RpcResult]).
|
||||||
|
*/
|
||||||
|
fun <P, R> send(
|
||||||
|
method: RpcMethod<P, R>,
|
||||||
|
params: P,
|
||||||
|
onSent: ((String) -> Unit)? = null,
|
||||||
|
): String = send(method.name, encodeParams(method, params), onSent)
|
||||||
|
|
||||||
private fun send(
|
private fun send(
|
||||||
method: String,
|
method: String,
|
||||||
params: Map<String, Any>,
|
params: Map<String, Any>,
|
||||||
@@ -1065,23 +1091,17 @@ object HermesWsClient {
|
|||||||
text: String,
|
text: String,
|
||||||
onSent: ((String) -> Unit)? = null,
|
onSent: ((String) -> Unit)? = null,
|
||||||
queued: Boolean = false,
|
queued: Boolean = false,
|
||||||
): String {
|
): String =
|
||||||
val params =
|
send(
|
||||||
buildMap {
|
method = RpcMethods.PROMPT_SUBMIT,
|
||||||
put("session_id", sessionId)
|
params =
|
||||||
put("text", text)
|
PromptSubmitParams(
|
||||||
// Explicit queue semantics: the gateway's busy-input policy
|
sessionId = sessionId,
|
||||||
// forces "run after, never interrupt" when prompt.submit
|
text = text,
|
||||||
// carries queued=true (hermes-agent methods_prompt.py
|
queued = queued.takeIf { it },
|
||||||
// _handle_busy_submit) — used by /queue.
|
),
|
||||||
if (queued) put("queued", true)
|
|
||||||
}
|
|
||||||
return send(
|
|
||||||
method = WsMethods.PROMPT_SUBMIT,
|
|
||||||
params = params,
|
|
||||||
onSent = onSent,
|
onSent = onSent,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience: redirect the active model turn while it is still generating
|
* Convenience: redirect the active model turn while it is still generating
|
||||||
|
|||||||
@@ -17,6 +17,33 @@ object RpcMethods {
|
|||||||
SessionEventsSinceResult.serializer(),
|
SessionEventsSinceResult.serializer(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val SESSION_CREATE: RpcMethod<SessionCreateParams, SessionCreateResult> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_CREATE,
|
||||||
|
SessionCreateParams.serializer(),
|
||||||
|
SessionCreateResult.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SESSION_RESUME: RpcMethod<SessionResumeParams, SessionResumeResult> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_RESUME,
|
||||||
|
SessionResumeParams.serializer(),
|
||||||
|
SessionResumeResult.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val PROMPT_SUBMIT: RpcMethod<PromptSubmitParams, PromptSubmitResult> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.PROMPT_SUBMIT,
|
||||||
|
PromptSubmitParams.serializer(),
|
||||||
|
PromptSubmitResult.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */
|
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */
|
||||||
val all: List<RpcMethod<*, *>> = listOf(SESSION_EVENTS_SINCE)
|
val all: List<RpcMethod<*, *>> =
|
||||||
|
listOf(
|
||||||
|
SESSION_EVENTS_SINCE,
|
||||||
|
SESSION_CREATE,
|
||||||
|
SESSION_RESUME,
|
||||||
|
PROMPT_SUBMIT,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionCreateParams(
|
||||||
|
@SerialName("source") val source: String? = null,
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
@SerialName("title") val title: String? = null,
|
||||||
|
@SerialName("hidden") val hidden: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionResumeParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("omit_messages") val omitMessages: Boolean? = null,
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PromptSubmitParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("text") val text: String,
|
||||||
|
@SerialName("queued") val queued: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// tolerant readers: all nullable with null defaults, only fields callers read
|
||||||
|
@Serializable
|
||||||
|
data class SessionCreateResult(
|
||||||
|
@SerialName("session_id") val sessionId: String? = null,
|
||||||
|
@SerialName("stored_session_id") val storedSessionId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionResumeResult(
|
||||||
|
@SerialName("session_id") val sessionId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PromptSubmitResult(
|
||||||
|
val status: String? = null,
|
||||||
|
)
|
||||||
@@ -11,7 +11,9 @@ import com.m57.hermescontrol.R
|
|||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.WsMethods
|
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -109,12 +111,11 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
val runtimeSessionId =
|
val runtimeSessionId =
|
||||||
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
|
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
|
||||||
?: resumeSession(sessionId)
|
?: resumeSession(sessionId)
|
||||||
HermesWsClient
|
HermesWsClient.call(
|
||||||
.request(
|
method = RpcMethods.PROMPT_SUBMIT,
|
||||||
WsMethods.PROMPT_SUBMIT,
|
params = PromptSubmitParams(sessionId = runtimeSessionId, text = replyText),
|
||||||
mapOf("session_id" to runtimeSessionId, "text" to replyText),
|
timeoutMs = REPLY_TIMEOUT_MS,
|
||||||
timeoutMs = REPLY_TIMEOUT_MS,
|
)
|
||||||
).await()
|
|
||||||
|
|
||||||
val entity =
|
val entity =
|
||||||
com.m57.hermescontrol.data.local.ChatMessageEntity(
|
com.m57.hermescontrol.data.local.ChatMessageEntity(
|
||||||
@@ -152,24 +153,21 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resumeSession(storedSessionId: String): String {
|
private suspend fun resumeSession(storedSessionId: String): String {
|
||||||
val profile = AuthManager.activeProfileId.value
|
val profile = AuthManager.activeProfileId.value?.takeIf { it.isNotBlank() }
|
||||||
val params =
|
val params =
|
||||||
mutableMapOf<String, Any>(
|
SessionResumeParams(
|
||||||
"session_id" to storedSessionId,
|
sessionId = storedSessionId,
|
||||||
"omit_messages" to true,
|
omitMessages = true,
|
||||||
|
profile = profile,
|
||||||
)
|
)
|
||||||
if (!profile.isNullOrBlank()) {
|
|
||||||
params["profile"] = profile
|
|
||||||
}
|
|
||||||
val result =
|
val result =
|
||||||
HermesWsClient
|
HermesWsClient.call(
|
||||||
.request(
|
method = RpcMethods.SESSION_RESUME,
|
||||||
WsMethods.SESSION_RESUME,
|
params = params,
|
||||||
params,
|
timeoutMs = REPLY_TIMEOUT_MS,
|
||||||
timeoutMs = REPLY_TIMEOUT_MS,
|
)
|
||||||
).await() as? Map<*, *>
|
|
||||||
val runtimeSessionId =
|
val runtimeSessionId =
|
||||||
(result?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
|
result.sessionId?.takeIf { it.isNotBlank() }
|
||||||
?: error("Resume returned no runtime session id")
|
?: error("Resume returned no runtime session id")
|
||||||
ActiveSessionHolder.set(runtimeSessionId, storedSessionId)
|
ActiveSessionHolder.set(runtimeSessionId, storedSessionId)
|
||||||
return runtimeSessionId
|
return runtimeSessionId
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
|||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import com.m57.hermescontrol.data.ws.WsMethods
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.SessionCreateParams
|
||||||
import com.m57.hermescontrol.data.ws.toJsonElement
|
import com.m57.hermescontrol.data.ws.toJsonElement
|
||||||
import com.m57.hermescontrol.ui.chat.tool.ToolResultSummary
|
import com.m57.hermescontrol.ui.chat.tool.ToolResultSummary
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
@@ -559,17 +562,24 @@ class GroupChatViewModel(
|
|||||||
|
|
||||||
val title = "Group: $groupName"
|
val title = "Group: $groupName"
|
||||||
try {
|
try {
|
||||||
val createParams =
|
val res =
|
||||||
buildMap<String, Any> {
|
HermesWsClient.call(
|
||||||
put("profile", bot.name)
|
method = RpcMethods.SESSION_CREATE,
|
||||||
put("title", title)
|
params =
|
||||||
put("source", "desktop")
|
SessionCreateParams(
|
||||||
put("hidden", true)
|
source = "desktop",
|
||||||
}
|
profile = bot.name,
|
||||||
val deferred = HermesWsClient.request(WsMethods.SESSION_CREATE, createParams)
|
title = title,
|
||||||
val res = deferred.await()
|
hidden = true,
|
||||||
val sessionInfo = extractSessionInfo(res)
|
),
|
||||||
if (sessionInfo != null) {
|
)
|
||||||
|
val runtimeId = res.sessionId?.takeIf { it.isNotBlank() }
|
||||||
|
if (runtimeId != null) {
|
||||||
|
val sessionInfo =
|
||||||
|
MemberSession(
|
||||||
|
runtimeSessionId = runtimeId,
|
||||||
|
storedSessionId = res.storedSessionId,
|
||||||
|
)
|
||||||
memberSessions[bot.name] = sessionInfo
|
memberSessions[bot.name] = sessionInfo
|
||||||
return sessionInfo
|
return sessionInfo
|
||||||
}
|
}
|
||||||
@@ -701,11 +711,10 @@ class GroupChatViewModel(
|
|||||||
// turn boundary can be proven for them. They stay uncorrelated
|
// turn boundary can be proven for them. They stay uncorrelated
|
||||||
// (reply notifications from group turns are never auto-dismissed
|
// (reply notifications from group turns are never auto-dismissed
|
||||||
// from REST hydration) instead of borrowing another turn's bound.
|
// from REST hydration) instead of borrowing another turn's bound.
|
||||||
HermesWsClient
|
HermesWsClient.call(
|
||||||
.request(
|
method = RpcMethods.PROMPT_SUBMIT,
|
||||||
WsMethods.PROMPT_SUBMIT,
|
params = PromptSubmitParams(sessionId = runtimeId, text = prompt),
|
||||||
mapOf("session_id" to runtimeId, "text" to prompt),
|
)
|
||||||
).await()
|
|
||||||
} catch (submitErr: Exception) {
|
} catch (submitErr: Exception) {
|
||||||
Log.w(
|
Log.w(
|
||||||
"GroupChatViewModel",
|
"GroupChatViewModel",
|
||||||
@@ -735,11 +744,10 @@ class GroupChatViewModel(
|
|||||||
storedId?.let { inFlightTurns[it] = turnDeferred }
|
storedId?.let { inFlightTurns[it] = turnDeferred }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
HermesWsClient
|
HermesWsClient.call(
|
||||||
.request(
|
method = RpcMethods.PROMPT_SUBMIT,
|
||||||
WsMethods.PROMPT_SUBMIT,
|
params = PromptSubmitParams(sessionId = runtimeId, text = prompt),
|
||||||
mapOf("session_id" to runtimeId, "text" to prompt),
|
)
|
||||||
).await()
|
|
||||||
} catch (retryErr: Exception) {
|
} catch (retryErr: Exception) {
|
||||||
Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}")
|
Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}")
|
||||||
return null
|
return null
|
||||||
@@ -1202,19 +1210,4 @@ class GroupChatViewModel(
|
|||||||
val clean = text.trim().lowercase()
|
val clean = text.trim().lowercase()
|
||||||
return clean == "(pass)" || clean == "pass" || clean == "(pass)." || clean == "pass."
|
return clean == "(pass)" || clean == "pass" || clean == "(pass)." || clean == "pass."
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractSessionInfo(response: Any?): MemberSession? {
|
|
||||||
if (response is Map<*, *>) {
|
|
||||||
val runtimeId =
|
|
||||||
response["session_id"]?.toString()?.trim('\"', ' ')
|
|
||||||
?: (response["result"] as? Map<*, *>)?.get("session_id")?.toString()?.trim('\"', ' ')
|
|
||||||
val storedId =
|
|
||||||
response["stored_session_id"]?.toString()?.trim('\"', ' ')
|
|
||||||
?: (response["result"] as? Map<*, *>)?.get("stored_session_id")?.toString()?.trim('\"', ' ')
|
|
||||||
if (!runtimeId.isNullOrBlank()) {
|
|
||||||
return MemberSession(runtimeSessionId = runtimeId, storedSessionId = storedId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
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.PromptSubmitParams
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.SessionCreateParams
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
|
||||||
|
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.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 HermesWsClientTypedSendTest {
|
||||||
|
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 typedSendSessionResumePutsExpectedFrameAndInvokesOnSent() {
|
||||||
|
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.resume") {
|
||||||
|
frameRef.set(json)
|
||||||
|
captured.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connectClient()
|
||||||
|
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
var onSentId: String? = null
|
||||||
|
val returnedId =
|
||||||
|
HermesWsClient.send(
|
||||||
|
method = RpcMethods.SESSION_RESUME,
|
||||||
|
params = SessionResumeParams(sessionId = "sess-1", omitMessages = true),
|
||||||
|
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.resume", (frame["method"] as? JsonPrimitive)?.content)
|
||||||
|
assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content)
|
||||||
|
|
||||||
|
val params = frame["params"] as? JsonObject
|
||||||
|
assertNotNull(params)
|
||||||
|
|
||||||
|
val expectedParams =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "sess-1")
|
||||||
|
put("omit_messages", true)
|
||||||
|
}
|
||||||
|
assertEquals(expectedParams, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun typedSendWithActiveProfileInjectsProfileIntoSessionResume() {
|
||||||
|
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.resume") {
|
||||||
|
frameRef.set(json)
|
||||||
|
captured.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
AuthManager.setActiveProfileId("active-profile-1")
|
||||||
|
connectClient()
|
||||||
|
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
HermesWsClient.send(
|
||||||
|
method = RpcMethods.SESSION_RESUME,
|
||||||
|
params = SessionResumeParams(sessionId = "sess-active", omitMessages = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||||
|
val frame = requireNotNull(frameRef.get())
|
||||||
|
val params = frame["params"] as? JsonObject
|
||||||
|
assertNotNull(params)
|
||||||
|
|
||||||
|
val expectedParams =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "sess-active")
|
||||||
|
put("omit_messages", true)
|
||||||
|
put("profile", "active-profile-1")
|
||||||
|
}
|
||||||
|
assertEquals(expectedParams, params)
|
||||||
|
assertEquals("active-profile-1", (params!!["profile"] as? JsonPrimitive)?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun typedSendExplicitProfileWinsOverActiveProfileForSessionResume() {
|
||||||
|
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.resume") {
|
||||||
|
frameRef.set(json)
|
||||||
|
captured.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
AuthManager.setActiveProfileId("active-profile-1")
|
||||||
|
connectClient()
|
||||||
|
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
HermesWsClient.send(
|
||||||
|
method = RpcMethods.SESSION_RESUME,
|
||||||
|
params =
|
||||||
|
SessionResumeParams(
|
||||||
|
sessionId = "sess-active",
|
||||||
|
omitMessages = true,
|
||||||
|
profile = "explicit-resume-profile",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||||
|
val frame = requireNotNull(frameRef.get())
|
||||||
|
val params = frame["params"] as? JsonObject
|
||||||
|
assertNotNull(params)
|
||||||
|
|
||||||
|
val expectedParams =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "sess-active")
|
||||||
|
put("omit_messages", true)
|
||||||
|
put("profile", "explicit-resume-profile")
|
||||||
|
}
|
||||||
|
assertEquals(expectedParams, params)
|
||||||
|
assertEquals("explicit-resume-profile", (params!!["profile"] as? JsonPrimitive)?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionCreateWithExplicitProfileKeepsExplicitWhenDifferentProfileActive() {
|
||||||
|
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.create") {
|
||||||
|
frameRef.set(json)
|
||||||
|
captured.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
AuthManager.setActiveProfileId("active-profile-2")
|
||||||
|
connectClient()
|
||||||
|
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
HermesWsClient.send(
|
||||||
|
method = RpcMethods.SESSION_CREATE,
|
||||||
|
params = SessionCreateParams(profile = "bot-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||||
|
val frame = requireNotNull(frameRef.get())
|
||||||
|
val params = frame["params"] as? JsonObject
|
||||||
|
assertNotNull(params)
|
||||||
|
|
||||||
|
val expectedParams =
|
||||||
|
buildJsonObject {
|
||||||
|
put("profile", "bot-1")
|
||||||
|
}
|
||||||
|
assertEquals(expectedParams, params)
|
||||||
|
assertEquals("bot-1", (params!!["profile"] as? JsonPrimitive)?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendMessagePutsSessionIdAndTextOnTheWire() {
|
||||||
|
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.submit") {
|
||||||
|
frameRef.set(json)
|
||||||
|
captured.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connectClient()
|
||||||
|
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
var onSentId: String? = null
|
||||||
|
val returnedId =
|
||||||
|
HermesWsClient.sendMessage(
|
||||||
|
sessionId = "sess-msg-1",
|
||||||
|
text = "plain message",
|
||||||
|
onSent = { id -> onSentId = id },
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(returnedId.isNotBlank())
|
||||||
|
assertEquals(returnedId, onSentId)
|
||||||
|
|
||||||
|
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||||
|
val frame = requireNotNull(frameRef.get())
|
||||||
|
val params = frame["params"] as? JsonObject
|
||||||
|
assertNotNull(params)
|
||||||
|
|
||||||
|
val expectedParams =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "sess-msg-1")
|
||||||
|
put("text", "plain message")
|
||||||
|
}
|
||||||
|
assertEquals(expectedParams, params)
|
||||||
|
assertFalse(params!!.containsKey("queued"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendMessageWithQueuedTrueAddsQueuedTrue() {
|
||||||
|
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.submit") {
|
||||||
|
frameRef.set(json)
|
||||||
|
captured.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connectClient()
|
||||||
|
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
HermesWsClient.sendMessage(
|
||||||
|
sessionId = "sess-msg-2",
|
||||||
|
text = "queued message",
|
||||||
|
queued = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||||
|
val frame = requireNotNull(frameRef.get())
|
||||||
|
val params = frame["params"] as? JsonObject
|
||||||
|
assertNotNull(params)
|
||||||
|
|
||||||
|
val expectedParams =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "sess-msg-2")
|
||||||
|
put("text", "queued message")
|
||||||
|
put("queued", true)
|
||||||
|
}
|
||||||
|
assertEquals(expectedParams, params)
|
||||||
|
val queuedElement = params!!["queued"] as? JsonPrimitive
|
||||||
|
assertTrue(queuedElement != null && queuedElement.isString.not())
|
||||||
|
assertEquals("true", queuedElement!!.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendMessageWithQueuedFalseHasNoQueuedKey() {
|
||||||
|
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.submit") {
|
||||||
|
frameRef.set(json)
|
||||||
|
captured.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connectClient()
|
||||||
|
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
HermesWsClient.sendMessage(
|
||||||
|
sessionId = "sess-msg-3",
|
||||||
|
text = "un为其queued message",
|
||||||
|
queued = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||||
|
val frame = requireNotNull(frameRef.get())
|
||||||
|
val params = frame["params"] as? JsonObject
|
||||||
|
assertNotNull(params)
|
||||||
|
|
||||||
|
val expectedParams =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "sess-msg-3")
|
||||||
|
put("text", "un为其queued message")
|
||||||
|
}
|
||||||
|
assertEquals(expectedParams, params)
|
||||||
|
assertFalse(params!!.containsKey("queued"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -334,7 +334,6 @@ class RawRpcParamsRatchetTest {
|
|||||||
"data/ws/ConnectorRepository.kt::CONNECTORS_CONNECT",
|
"data/ws/ConnectorRepository.kt::CONNECTORS_CONNECT",
|
||||||
"data/ws/ConnectorRepository.kt::CONNECTORS_LIST",
|
"data/ws/ConnectorRepository.kt::CONNECTORS_LIST",
|
||||||
"data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES",
|
"data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES",
|
||||||
"data/ws/HermesWsClient.kt::PROMPT_SUBMIT",
|
|
||||||
"data/ws/HermesWsClient.kt::SESSION_REDIRECT",
|
"data/ws/HermesWsClient.kt::SESSION_REDIRECT",
|
||||||
"data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS",
|
"data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS",
|
||||||
"data/ws/PluginManageRepository.kt::PLUGINS_MANAGE",
|
"data/ws/PluginManageRepository.kt::PLUGINS_MANAGE",
|
||||||
@@ -349,8 +348,6 @@ class RawRpcParamsRatchetTest {
|
|||||||
"data/ws/VaultRepository.kt::VAULT_SOURCES",
|
"data/ws/VaultRepository.kt::VAULT_SOURCES",
|
||||||
"data/ws/VaultRepository.kt::VAULT_SOURCE_SET",
|
"data/ws/VaultRepository.kt::VAULT_SOURCE_SET",
|
||||||
"data/ws/VaultRepository.kt::VAULT_UNLOCK",
|
"data/ws/VaultRepository.kt::VAULT_UNLOCK",
|
||||||
"notification/NotificationReplyReceiver.kt::PROMPT_SUBMIT",
|
|
||||||
"notification/NotificationReplyReceiver.kt::SESSION_RESUME",
|
|
||||||
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
|
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
|
||||||
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
|
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
|
||||||
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
|
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
|
||||||
@@ -359,9 +356,6 @@ class RawRpcParamsRatchetTest {
|
|||||||
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
|
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
|
||||||
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
|
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
|
||||||
"ui/bots/group/GroupChatViewModel.kt::PROFILES_LIST",
|
"ui/bots/group/GroupChatViewModel.kt::PROFILES_LIST",
|
||||||
"ui/bots/group/GroupChatViewModel.kt::PROMPT_SUBMIT",
|
|
||||||
"ui/bots/group/GroupChatViewModel.kt::PROMPT_SUBMIT",
|
|
||||||
"ui/bots/group/GroupChatViewModel.kt::SESSION_CREATE",
|
|
||||||
"ui/bots/group/GroupChatViewModel.kt::SESSION_INTERRUPT",
|
"ui/bots/group/GroupChatViewModel.kt::SESSION_INTERRUPT",
|
||||||
"ui/chat/ChatApprovalsDelegate.kt::APPROVAL_PENDING",
|
"ui/chat/ChatApprovalsDelegate.kt::APPROVAL_PENDING",
|
||||||
"ui/chat/ChatApprovalsDelegate.kt::APPROVAL_RECEIVED",
|
"ui/chat/ChatApprovalsDelegate.kt::APPROVAL_RECEIVED",
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
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 SessionPromptTypesTest {
|
||||||
|
@Test
|
||||||
|
fun createWithSourceOnlyEncodesExactlySource() {
|
||||||
|
val params = SessionCreateParams(source = "cli")
|
||||||
|
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
|
||||||
|
|
||||||
|
val expected =
|
||||||
|
buildJsonObject {
|
||||||
|
put("source", "cli")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(expected, encoded)
|
||||||
|
assertTrue(encoded is JsonObject)
|
||||||
|
assertEquals(setOf("source"), (encoded as JsonObject).keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun groupStyleCreateEncodesExpectedFourKeysWithHiddenBoolean() {
|
||||||
|
val params =
|
||||||
|
SessionCreateParams(
|
||||||
|
profile = "work",
|
||||||
|
title = "Team sync",
|
||||||
|
source = "matrix",
|
||||||
|
hidden = true,
|
||||||
|
)
|
||||||
|
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
|
||||||
|
|
||||||
|
val expected =
|
||||||
|
buildJsonObject {
|
||||||
|
put("source", "matrix")
|
||||||
|
put("profile", "work")
|
||||||
|
put("title", "Team sync")
|
||||||
|
put("hidden", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(expected, encoded)
|
||||||
|
assertTrue(encoded is JsonObject)
|
||||||
|
val hiddenElement = (encoded as JsonObject)["hidden"] as? JsonPrimitive
|
||||||
|
assertTrue(hiddenElement != null && hiddenElement.isString.not())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allNullCreateParamsEncodeToEmptyObject() {
|
||||||
|
val params = SessionCreateParams()
|
||||||
|
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
|
||||||
|
|
||||||
|
val expected = buildJsonObject {}
|
||||||
|
assertEquals(expected, encoded)
|
||||||
|
assertTrue(encoded is JsonObject)
|
||||||
|
assertTrue((encoded as JsonObject).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createWithExplicitFalseHiddenIsEncoded() {
|
||||||
|
val params = SessionCreateParams(hidden = false)
|
||||||
|
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
|
||||||
|
|
||||||
|
val expected =
|
||||||
|
buildJsonObject {
|
||||||
|
put("hidden", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(expected, encoded)
|
||||||
|
assertTrue(encoded is JsonObject)
|
||||||
|
assertEquals(setOf("hidden"), (encoded as JsonObject).keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resumeEncodesSessionIdAndOmitMessagesExactly() {
|
||||||
|
val params = SessionResumeParams(sessionId = "s-123", omitMessages = true)
|
||||||
|
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
|
||||||
|
|
||||||
|
val expected =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "s-123")
|
||||||
|
put("omit_messages", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(expected, encoded)
|
||||||
|
assertTrue(encoded is JsonObject)
|
||||||
|
assertEquals(setOf("session_id", "omit_messages"), (encoded as JsonObject).keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resumeWithProfileEncodesExpectedThreeKeys() {
|
||||||
|
val params =
|
||||||
|
SessionResumeParams(
|
||||||
|
sessionId = "s-456",
|
||||||
|
omitMessages = true,
|
||||||
|
profile = "personal",
|
||||||
|
)
|
||||||
|
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
|
||||||
|
|
||||||
|
val expected =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "s-456")
|
||||||
|
put("omit_messages", true)
|
||||||
|
put("profile", "personal")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(expected, encoded)
|
||||||
|
assertTrue(encoded is JsonObject)
|
||||||
|
assertEquals(setOf("session_id", "omit_messages", "profile"), (encoded as JsonObject).keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resumeWithExplicitFalseOmitMessagesIsEncoded() {
|
||||||
|
val params = SessionResumeParams(sessionId = "s-789", omitMessages = false)
|
||||||
|
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
|
||||||
|
|
||||||
|
val expected =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "s-789")
|
||||||
|
put("omit_messages", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(expected, encoded)
|
||||||
|
assertTrue(encoded is JsonObject)
|
||||||
|
assertEquals(setOf("session_id", "omit_messages"), (encoded as JsonObject).keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun submitEncodesSessionIdAndText() {
|
||||||
|
val params = PromptSubmitParams(sessionId = "s-100", text = "hello hermes")
|
||||||
|
val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params)
|
||||||
|
|
||||||
|
val expected =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "s-100")
|
||||||
|
put("text", "hello hermes")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(expected, encoded)
|
||||||
|
assertTrue(encoded is JsonObject)
|
||||||
|
assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun submitWithQueuedTrueEncodesQueued() {
|
||||||
|
val params = PromptSubmitParams(sessionId = "s-101", text = "queued prompt", queued = true)
|
||||||
|
val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params)
|
||||||
|
|
||||||
|
val expected =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "s-101")
|
||||||
|
put("text", "queued prompt")
|
||||||
|
put("queued", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(expected, encoded)
|
||||||
|
assertTrue(encoded is JsonObject)
|
||||||
|
assertEquals(setOf("session_id", "text", "queued"), (encoded as JsonObject).keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun submitWithExplicitFalseQueuedIsEncoded() {
|
||||||
|
val params = PromptSubmitParams(sessionId = "s-102", text = "direct prompt", queued = false)
|
||||||
|
val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params)
|
||||||
|
|
||||||
|
val expected =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "s-102")
|
||||||
|
put("text", "direct prompt")
|
||||||
|
put("queued", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(expected, encoded)
|
||||||
|
assertTrue(encoded is JsonObject)
|
||||||
|
assertEquals(setOf("session_id", "text", "queued"), (encoded as JsonObject).keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionCreateResultDecodesFullPayloadAndIgnoresUnknownKeys() {
|
||||||
|
val payload =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "sess-created-1")
|
||||||
|
put("stored_session_id", "stored-1")
|
||||||
|
put("created_at", 123456789)
|
||||||
|
put("unknown_flag", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val decoded =
|
||||||
|
OkHttpProvider.json.decodeFromJsonElement(
|
||||||
|
SessionCreateResult.serializer(),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("sess-created-1", decoded.sessionId)
|
||||||
|
assertEquals("stored-1", decoded.storedSessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionCreateResultDecodesEmptyObjectToNulls() {
|
||||||
|
val payload = buildJsonObject {}
|
||||||
|
val decoded =
|
||||||
|
OkHttpProvider.json.decodeFromJsonElement(
|
||||||
|
SessionCreateResult.serializer(),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(decoded.sessionId)
|
||||||
|
assertNull(decoded.storedSessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionResumeResultDecodesFullPayloadAndIgnoresUnknownKeys() {
|
||||||
|
val payload =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "sess-resumed-1")
|
||||||
|
put("active_model", "gemini")
|
||||||
|
put("unread_count", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
val decoded =
|
||||||
|
OkHttpProvider.json.decodeFromJsonElement(
|
||||||
|
SessionResumeResult.serializer(),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("sess-resumed-1", decoded.sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionResumeResultDecodesEmptyObjectToNulls() {
|
||||||
|
val payload = buildJsonObject {}
|
||||||
|
val decoded =
|
||||||
|
OkHttpProvider.json.decodeFromJsonElement(
|
||||||
|
SessionResumeResult.serializer(),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(decoded.sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun promptSubmitResultDecodesFullPayloadAndIgnoresUnknownKeys() {
|
||||||
|
val payload =
|
||||||
|
buildJsonObject {
|
||||||
|
put("status", "accepted")
|
||||||
|
put("ticket_id", "tick-42")
|
||||||
|
put("queued_at", "2026-09-29T10:00:00Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
val decoded =
|
||||||
|
OkHttpProvider.json.decodeFromJsonElement(
|
||||||
|
PromptSubmitResult.serializer(),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("accepted", decoded.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun promptSubmitResultDecodesEmptyObjectToNulls() {
|
||||||
|
val payload = buildJsonObject {}
|
||||||
|
val decoded =
|
||||||
|
OkHttpProvider.json.decodeFromJsonElement(
|
||||||
|
PromptSubmitResult.serializer(),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(decoded.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rpcMethodsRegistrationMatchesContract() {
|
||||||
|
assertEquals("session.create", RpcMethods.SESSION_CREATE.name)
|
||||||
|
assertEquals("session.resume", RpcMethods.SESSION_RESUME.name)
|
||||||
|
assertEquals("prompt.submit", RpcMethods.PROMPT_SUBMIT.name)
|
||||||
|
|
||||||
|
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_CREATE))
|
||||||
|
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_RESUME))
|
||||||
|
assertTrue(RpcMethods.all.contains(RpcMethods.PROMPT_SUBMIT))
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-5
@@ -21,6 +21,7 @@ import io.mockk.slot
|
|||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -108,7 +109,7 @@ class NotificationReplyReceiverTest {
|
|||||||
val params = arg<Map<String, Any>>(1)
|
val params = arg<Map<String, Any>>(1)
|
||||||
val result =
|
val result =
|
||||||
if (method == WsMethods.SESSION_RESUME) {
|
if (method == WsMethods.SESSION_RESUME) {
|
||||||
mapOf("session_id" to "runtime-${params["session_id"]}")
|
mapOf("session_id" to "runtime-${params.str("session_id")}")
|
||||||
} else {
|
} else {
|
||||||
mapOf("accepted" to true)
|
mapOf("accepted" to true)
|
||||||
}
|
}
|
||||||
@@ -147,7 +148,7 @@ class NotificationReplyReceiverTest {
|
|||||||
verify {
|
verify {
|
||||||
HermesWsClient.request(
|
HermesWsClient.request(
|
||||||
WsMethods.PROMPT_SUBMIT,
|
WsMethods.PROMPT_SUBMIT,
|
||||||
match { it["session_id"] == "runtime-session-abc" && it["text"] == "Hello" },
|
match { it.str("session_id") == "runtime-session-abc" && it.str("text") == "Hello" },
|
||||||
any(),
|
any(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -329,19 +330,19 @@ class NotificationReplyReceiverTest {
|
|||||||
verify {
|
verify {
|
||||||
HermesWsClient.request(
|
HermesWsClient.request(
|
||||||
WsMethods.SESSION_RESUME,
|
WsMethods.SESSION_RESUME,
|
||||||
match { it["session_id"] == "session-abc" && it["omit_messages"] == true },
|
match { it.str("session_id") == "session-abc" && it["omit_messages"] == JsonPrimitive(true) },
|
||||||
any(),
|
any(),
|
||||||
)
|
)
|
||||||
HermesWsClient.request(
|
HermesWsClient.request(
|
||||||
WsMethods.PROMPT_SUBMIT,
|
WsMethods.PROMPT_SUBMIT,
|
||||||
match { it["session_id"] == "runtime-session-abc" },
|
match { it.str("session_id") == "runtime-session-abc" },
|
||||||
any(),
|
any(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
verify(inverse = true) {
|
verify(inverse = true) {
|
||||||
HermesWsClient.request(
|
HermesWsClient.request(
|
||||||
WsMethods.PROMPT_SUBMIT,
|
WsMethods.PROMPT_SUBMIT,
|
||||||
match { it["session_id"] == "stale-runtime" },
|
match { it.str("session_id") == "stale-runtime" },
|
||||||
any(),
|
any(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -454,3 +455,5 @@ class NotificationReplyReceiverTest {
|
|||||||
every { mockIntent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) } returns sessionId
|
every { mockIntent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) } returns sessionId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Map<String, Any>.str(key: String): String? = (this[key] as? JsonPrimitive)?.content ?: this[key] as? String
|
||||||
Reference in new issue
Block a user