refactor(#1375): type config.set, config.get, model.options and client.capabilities (#1393)

This commit is contained in:
m57
2026-09-30 00:40:24 +03:00
committed by GitHub
parent cb0463aefc
commit 1456e642e3
12 changed files with 339 additions and 207 deletions
@@ -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.ClientCapabilitiesParams
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams 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
@@ -364,8 +365,11 @@ object HermesWsClient {
// Older gateways may reject this method; that is harmless. // Older gateways may reject this method; that is harmless.
runCatching { runCatching {
send( send(
WsMethods.CLIENT_CAPABILITIES, RpcMethods.CLIENT_CAPABILITIES.name,
mapOf("server_requests" to true), encodeParams(
RpcMethods.CLIENT_CAPABILITIES,
ClientCapabilitiesParams(serverRequests = true),
),
onSent = { id -> capabilityRequestIds.add(id) }, onSent = { id -> capabilityRequestIds.add(id) },
queueIfDisconnected = false, queueIfDisconnected = false,
) )
@@ -5,6 +5,8 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.contract.ModelOptionsParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
@@ -16,13 +18,8 @@ class ModelOptionsRepository(
private val connected: () -> Boolean = { private val connected: () -> Boolean = {
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
}, },
private val request: suspend (Map<String, Any>) -> Any? = { params -> private val request: suspend (ModelOptionsParams) -> JsonElement = { params ->
val deferred = HermesWsClient.request(WsMethods.MODEL_OPTIONS, params) HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params)
try {
deferred.await()
} finally {
if (!deferred.isCompleted) deferred.cancel()
}
}, },
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh -> private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) } safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
@@ -32,9 +29,7 @@ class ModelOptionsRepository(
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
if (connected()) { if (connected()) {
try { try {
val result = request(mapOf("refresh" to refresh, "include_unconfigured" to false)) val json = request(ModelOptionsParams(refresh = refresh, includeUnconfigured = false))
// request() returns raw JsonElement, unlike the pushed-event Map representation.
val json = result as? JsonElement ?: error("Invalid model.options result")
return NetworkResult.Success(OkHttpProvider.json.decodeFromJsonElement<ModelOptionsResponse>(json)) return NetworkResult.Success(OkHttpProvider.json.decodeFromJsonElement<ModelOptionsResponse>(json))
} catch (e: CancellationException) { } catch (e: CancellationException) {
// A disconnected socket may cancel the independent RPC deferred. Only fall back // A disconnected socket may cancel the independent RPC deferred. Only fall back
@@ -0,0 +1,41 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* `config.set` params. [key] and [value] are always encoded (no defaults); the rest are omitted
* when null. `profile` is injected by WsProfileParams, so it is not a field here.
*/
@Serializable
data class ConfigSetParams(
@SerialName("key") val key: String,
@SerialName("value") val value: String,
@SerialName("session_id") val sessionId: String? = null,
@SerialName("scope") val scope: String? = null,
@SerialName("confirm_expensive_model") val confirmExpensiveModel: Boolean? = null,
)
@Serializable
data class ConfigGetParams(
@SerialName("key") val key: String,
@SerialName("session_id") val sessionId: String? = null,
@SerialName("cwd") val cwd: String? = null,
)
/**
* `model.options` params. Flags are `Boolean?` so an explicit `false` is still sent while an
* unset flag is omitted (the client Json has `encodeDefaults = false`).
*/
@Serializable
data class ModelOptionsParams(
@SerialName("session_id") val sessionId: String? = null,
@SerialName("explicit_only") val explicitOnly: Boolean? = null,
@SerialName("include_unconfigured") val includeUnconfigured: Boolean? = null,
@SerialName("refresh") val refresh: Boolean? = null,
)
@Serializable
data class ClientCapabilitiesParams(
@SerialName("server_requests") val serverRequests: Boolean,
)
@@ -395,6 +395,38 @@ object RpcMethods {
JsonElement.serializer(), JsonElement.serializer(),
) )
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val CONFIG_SET: RpcMethod<ConfigSetParams, JsonElement> =
RpcMethod(
WsMethods.CONFIG_SET,
ConfigSetParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val CONFIG_GET: RpcMethod<ConfigGetParams, JsonElement> =
RpcMethod(
WsMethods.CONFIG_GET,
ConfigGetParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val MODEL_OPTIONS: RpcMethod<ModelOptionsParams, JsonElement> =
RpcMethod(
WsMethods.MODEL_OPTIONS,
ModelOptionsParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val CLIENT_CAPABILITIES: RpcMethod<ClientCapabilitiesParams, JsonElement> =
RpcMethod(
WsMethods.CLIENT_CAPABILITIES,
ClientCapabilitiesParams.serializer(),
JsonElement.serializer(),
)
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */ /** Every typed method. GatewayContractTest iterates this; future migrations append here. */
val all: List<RpcMethod<*, *>> = val all: List<RpcMethod<*, *>> =
listOf( listOf(
@@ -446,5 +478,9 @@ object RpcMethods {
PROJECTS_LIST, PROJECTS_LIST,
PROFILES_LIST, PROFILES_LIST,
PROFILES_CONFIGURE, PROFILES_CONFIGURE,
CONFIG_SET,
CONFIG_GET,
MODEL_OPTIONS,
CLIENT_CAPABILITIES,
) )
} }
@@ -12,7 +12,8 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.ModelCatalogStore import com.m57.hermescontrol.data.ws.ModelCatalogStore
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.toAny import com.m57.hermescontrol.data.ws.toAny
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -51,7 +52,7 @@ class ChatModelSwitchDelegate(
private val ioDispatcher: CoroutineDispatcher, private val ioDispatcher: CoroutineDispatcher,
private val uiState: MutableStateFlow<ChatUiState>, private val uiState: MutableStateFlow<ChatUiState>,
private val runtimeSessionId: () -> String?, private val runtimeSessionId: () -> String?,
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit, private val wsSend: (params: ConfigSetParams, onSent: ((String) -> Unit)?) -> Unit,
private val trackRequest: (id: String, method: String) -> Unit, private val trackRequest: (id: String, method: String) -> Unit,
private val addAssistantMessage: (text: String) -> Unit, private val addAssistantMessage: (text: String) -> Unit,
private val handleSlashCommand: (command: String) -> Unit, private val handleSlashCommand: (command: String) -> Unit,
@@ -64,7 +65,7 @@ class ChatModelSwitchDelegate(
{ refresh -> ModelCatalogStore.shared.ensureLoaded(forceRefresh = refresh) }, { refresh -> ModelCatalogStore.shared.ensureLoaded(forceRefresh = refresh) },
private val getPinnedModels: () -> List<PinnedModel> = { AuthManager.getPinnedModels() }, private val getPinnedModels: () -> List<PinnedModel> = { AuthManager.getPinnedModels() },
private val savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) }, private val savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) },
private val wsRequest: (suspend (method: String, params: Map<String, Any>) -> Any?)? = null, private val wsRequest: (suspend (params: ConfigSetParams) -> JsonElement)? = null,
) { ) {
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>() private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
private val pendingFastSwitchRequests = ConcurrentHashMap<String, ActiveFastSwitch>() private val pendingFastSwitchRequests = ConcurrentHashMap<String, ActiveFastSwitch>()
@@ -300,20 +301,15 @@ class ChatModelSwitchDelegate(
unconfirmedTargetModel = spec unconfirmedTargetModel = spec
val switchSeq = ++modelSwitchSequence val switchSeq = ++modelSwitchSequence
val params = val params =
mutableMapOf<String, Any>( ConfigSetParams(
"key" to "model", key = "model",
"value" to spec, value = spec,
"session_id" to sessionId, sessionId = sessionId,
confirmExpensiveModel = true.takeIf { confirmExpensive },
) )
if (confirmExpensive) {
params["confirm_expensive_model"] = true
}
scope.launch(ioDispatcher) { scope.launch(ioDispatcher) {
wsSend( wsSend(params) { id ->
WsMethods.CONFIG_SET, trackRequest(id, RpcMethods.CONFIG_SET.name)
params,
) { id ->
trackRequest(id, WsMethods.CONFIG_SET)
pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel, switchSeq) pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel, switchSeq)
} }
} }
@@ -408,14 +404,13 @@ class ChatModelSwitchDelegate(
uiState.update { it.copy(isFastModeChanging = true) } uiState.update { it.copy(isFastModeChanging = true) }
scope.launch(ioDispatcher) { scope.launch(ioDispatcher) {
wsSend( wsSend(
WsMethods.CONFIG_SET, ConfigSetParams(
mapOf( key = "fast",
"key" to "fast", value = if (target) "fast" else "normal",
"value" to if (target) "fast" else "normal", sessionId = sessionId,
"session_id" to sessionId,
), ),
) { id -> ) { id ->
trackRequest(id, WsMethods.CONFIG_SET) trackRequest(id, RpcMethods.CONFIG_SET.name)
pendingFastSwitchRequests[id] = ActiveFastSwitch(target) pendingFastSwitchRequests[id] = ActiveFastSwitch(target)
} }
} }
@@ -539,18 +534,18 @@ class ChatModelSwitchDelegate(
scope.launch(ioDispatcher) { scope.launch(ioDispatcher) {
try { try {
val params = val params =
mapOf( ConfigSetParams(
"key" to "reasoning", key = "reasoning",
"value" to level, value = level,
"session_id" to sessionId, sessionId = sessionId,
"scope" to scopeName, scope = scopeName,
) )
val result = val result =
if (wsRequest != null) { if (wsRequest != null) {
wsRequest.invoke(WsMethods.CONFIG_SET, params) wsRequest.invoke(params)
} else { } else {
wsSend(WsMethods.CONFIG_SET, params) { reqId -> wsSend(params) { reqId ->
trackRequest(reqId, WsMethods.CONFIG_SET) trackRequest(reqId, RpcMethods.CONFIG_SET.name)
} }
null null
} }
@@ -558,14 +553,7 @@ class ChatModelSwitchDelegate(
if (opSeq == reasoningOpSequence && capturedScope == dataScopeFlow.value) { if (opSeq == reasoningOpSequence && capturedScope == dataScopeFlow.value) {
val ackLevel = val ackLevel =
if (wsRequest != null) { if (wsRequest != null) {
// HermesWsClient.request() completes with the RAW JsonElement. val map = result?.toAny() as? Map<*, *>
// Tests and a few injected callers may provide an already-decoded map.
val map =
when (result) {
is JsonElement -> result.toAny() as? Map<*, *>
is Map<*, *> -> result
else -> null
}
val resKey = map?.get("key") as? String val resKey = map?.get("key") as? String
val resVal = map?.get("value") as? String val resVal = map?.get("value") as? String
if (resKey != "reasoning" || resVal.isNullOrEmpty()) { if (resKey != "reasoning" || resVal.isNullOrEmpty()) {
@@ -37,6 +37,8 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.JsonRpcError import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.contract.ConfigGetParams
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
import com.m57.hermescontrol.data.ws.contract.PromptBtwParams import com.m57.hermescontrol.data.ws.contract.PromptBtwParams
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
@@ -694,13 +696,13 @@ class ChatViewModel(
ioDispatcher = ioDispatcher, ioDispatcher = ioDispatcher,
uiState = _uiState, uiState = _uiState,
runtimeSessionId = { runtimeSessionId }, runtimeSessionId = { runtimeSessionId },
wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) }, wsSend = { params, onSent -> wsClient.send(RpcMethods.CONFIG_SET, params, onSent) },
trackRequest = { id, method -> trackRequest(id, method) }, trackRequest = { id, method -> trackRequest(id, method) },
addAssistantMessage = { text -> addAssistantMessage(text) }, addAssistantMessage = { text -> addAssistantMessage(text) },
handleSlashCommand = { cmd -> handleSlashCommand(cmd) }, handleSlashCommand = { cmd -> handleSlashCommand(cmd) },
fetchContextUsage = { fetchContextUsage() }, fetchContextUsage = { fetchContextUsage() },
onModelSwitchInitiated = { onModelSwitchInitiated() }, onModelSwitchInitiated = { onModelSwitchInitiated() },
wsRequest = { method, params -> wsClient.request(method, params).await() }, wsRequest = { params -> wsClient.call(RpcMethods.CONFIG_SET, params) },
).apply { ).apply {
attachScopeObserver(viewModelScope) attachScopeObserver(viewModelScope)
} }
@@ -3846,14 +3848,10 @@ class ChatViewModel(
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
try { try {
val result = val result =
wsClient wsClient.call(
.request( RpcMethods.CONFIG_GET,
WsMethods.CONFIG_GET, ConfigGetParams(key = "reasoning", sessionId = sessionId),
mapOf( )
"key" to "reasoning",
"session_id" to sessionId,
),
).await()
val map = rpcResultMap(result) ?: error("Invalid reasoning config.get response") val map = rpcResultMap(result) ?: error("Invalid reasoning config.get response")
val value = (map["value"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown" val value = (map["value"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown"
val display = (map["display"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown" val display = (map["display"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown"
@@ -3901,13 +3899,13 @@ class ChatViewModel(
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
try { try {
val params = val params =
buildMap<String, Any> { ConfigSetParams(
put("key", "reasoning") key = "reasoning",
put("value", parsed.value) value = parsed.value,
put("session_id", sessionId) sessionId = sessionId,
parsed.scopeName?.let { put("scope", it) } scope = parsed.scopeName,
} )
val result = wsClient.request(WsMethods.CONFIG_SET, params).await() val result = wsClient.call(RpcMethods.CONFIG_SET, params)
val map = rpcResultMap(result) ?: error("Invalid reasoning config.set response") val map = rpcResultMap(result) ?: error("Invalid reasoning config.set response")
val responseKey = map["key"] as? String val responseKey = map["key"] as? String
val responseValue = map["value"] as? String val responseValue = map["value"] as? String
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ModelOptionsResponse import com.m57.hermescontrol.data.model.ModelOptionsResponse
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.ws.contract.ModelOptionsParams
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitCancellation
@@ -21,7 +22,7 @@ class ModelOptionsRepositoryTest {
ModelOptionsRepository( ModelOptionsRepository(
connected = { true }, connected = { true },
request = { params -> request = { params ->
assertEquals(mapOf("refresh" to true, "include_unconfigured" to false), params) assertEquals(ModelOptionsParams(refresh = true, includeUnconfigured = false), params)
Json.parseToJsonElement("""{"providers":[{"slug":"test","name":"Test","models":["a"]}]}""") Json.parseToJsonElement("""{"providers":[{"slug":"test","name":"Test","models":["a"]}]}""")
}, },
rest = { error("REST must not run") }, rest = { error("REST must not run") },
@@ -316,17 +316,9 @@ class RawRpcParamsRatchetTest {
*/ */
val BASELINE: List<String> = val BASELINE: List<String> =
listOf( listOf(
"data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES",
"data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS",
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
"ui/chat/ChatViewModel.kt::COMMANDS_CATALOG", "ui/chat/ChatViewModel.kt::COMMANDS_CATALOG",
"ui/chat/ChatViewModel.kt::COMMAND_DISPATCH", "ui/chat/ChatViewModel.kt::COMMAND_DISPATCH",
"ui/chat/ChatViewModel.kt::COMMAND_DISPATCH", "ui/chat/ChatViewModel.kt::COMMAND_DISPATCH",
"ui/chat/ChatViewModel.kt::CONFIG_GET",
"ui/chat/ChatViewModel.kt::CONFIG_SET",
"ui/chat/ChatViewModel.kt::FILE_ATTACH", "ui/chat/ChatViewModel.kt::FILE_ATTACH",
"ui/chat/ChatViewModel.kt::IMAGE_ATTACH_BYTES", "ui/chat/ChatViewModel.kt::IMAGE_ATTACH_BYTES",
"ui/chat/ChatViewModel.kt::SLASH_EXEC", "ui/chat/ChatViewModel.kt::SLASH_EXEC",
@@ -0,0 +1,102 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ConfigModelTypesTest {
private val json = OkHttpProvider.json
@Test
fun configSetEncodesOnlyKeyAndValueByDefault() {
assertEquals(
buildJsonObject {
put("key", "fast")
put("value", "normal")
},
json.encodeToJsonElement(ConfigSetParams.serializer(), ConfigSetParams(key = "fast", value = "normal")),
)
}
@Test
fun configSetEncodesSessionScopeAndConfirmWhenPresent() {
assertEquals(
buildJsonObject {
put("key", "model")
put("value", "gpt-4o --provider openai --session")
put("session_id", "s1")
put("scope", "global")
put("confirm_expensive_model", true)
},
json.encodeToJsonElement(
ConfigSetParams.serializer(),
ConfigSetParams(
key = "model",
value = "gpt-4o --provider openai --session",
sessionId = "s1",
scope = "global",
confirmExpensiveModel = true,
),
),
)
}
@Test
fun configGetOmitsNullSessionAndCwd() {
assertEquals(
buildJsonObject {
put("key", "reasoning")
put("session_id", "s1")
},
json.encodeToJsonElement(
ConfigGetParams.serializer(),
ConfigGetParams(key = "reasoning", sessionId = "s1"),
),
)
}
@Test
fun modelOptionsKeepsExplicitFalseAndOmitsUnsetFlags() {
assertEquals(
buildJsonObject {
put("refresh", true)
put("include_unconfigured", false)
},
json.encodeToJsonElement(
ModelOptionsParams.serializer(),
ModelOptionsParams(refresh = true, includeUnconfigured = false),
),
)
assertEquals(
buildJsonObject { },
json.encodeToJsonElement(ModelOptionsParams.serializer(), ModelOptionsParams()),
)
}
@Test
fun clientCapabilitiesAlwaysEncodesServerRequests() {
assertEquals(
buildJsonObject { put("server_requests", true) },
json.encodeToJsonElement(
ClientCapabilitiesParams.serializer(),
ClientCapabilitiesParams(serverRequests = true),
),
)
}
@Test
fun descriptorsAreRegisteredWithContractNames() {
listOf(
RpcMethods.CONFIG_SET to "config.set",
RpcMethods.CONFIG_GET to "config.get",
RpcMethods.MODEL_OPTIONS to "model.options",
RpcMethods.CLIENT_CAPABILITIES to "client.capabilities",
).forEach { (method, name) ->
assertTrue("$name not in RpcMethods.all", method in RpcMethods.all)
assertEquals(name, method.name)
}
}
}
@@ -8,7 +8,7 @@ import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,6 +18,8 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -31,8 +33,7 @@ class ChatModelSwitchDelegateTest {
private val uiState = private val uiState =
MutableStateFlow(ChatUiState(currentSessionId = "sess-1", currentSessionModel = "anthropic/claude-3")) MutableStateFlow(ChatUiState(currentSessionId = "sess-1", currentSessionModel = "anthropic/claude-3"))
private var runtimeId: String? = "runtime-1" private var runtimeId: String? = "runtime-1"
private val sentMethods = mutableListOf<String>() private val sentParams = mutableListOf<ConfigSetParams>()
private val sentParams = mutableListOf<Map<String, Any>>()
private val slashCommands = mutableListOf<String>() private val slashCommands = mutableListOf<String>()
private val assistantMessages = mutableListOf<String>() private val assistantMessages = mutableListOf<String>()
private var contextRefetched = 0 private var contextRefetched = 0
@@ -71,10 +72,9 @@ class ChatModelSwitchDelegateTest {
ioDispatcher = testDispatcher, ioDispatcher = testDispatcher,
uiState = uiState, uiState = uiState,
runtimeSessionId = { runtimeId }, runtimeSessionId = { runtimeId },
wsSend = { method, params, onSent -> wsSend = { params, onSent ->
sentMethods.add(method)
sentParams.add(params) sentParams.add(params)
onSent?.invoke("req-${sentMethods.size}") onSent?.invoke("req-${sentParams.size}")
}, },
trackRequest = { _, _ -> }, trackRequest = { _, _ -> },
addAssistantMessage = { assistantMessages.add(it) }, addAssistantMessage = { assistantMessages.add(it) },
@@ -304,8 +304,9 @@ class ChatModelSwitchDelegateTest {
delegate.handleModelSwitch("/MODEL gpt-4o --provider openai --session") delegate.handleModelSwitch("/MODEL gpt-4o --provider openai --session")
advanceUntilIdle() advanceUntilIdle()
assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods) assertEquals(1, sentParams.size)
assertEquals("gpt-4o --provider openai --session", sentParams.first()["value"]) assertEquals("model", sentParams.first().key)
assertEquals("gpt-4o --provider openai --session", sentParams.first().value)
delegate.handleConfigSetResult( delegate.handleConfigSetResult(
id = "req-1", id = "req-1",
@@ -320,8 +321,8 @@ class ChatModelSwitchDelegateTest {
delegate.confirmModelSwitchExpensive() delegate.confirmModelSwitchExpensive()
advanceUntilIdle() advanceUntilIdle()
assertEquals(2, sentMethods.size) assertEquals(2, sentParams.size)
assertEquals(true, sentParams[1]["confirm_expensive_model"]) assertEquals(true, sentParams[1].confirmExpensiveModel)
} }
@Test @Test
@@ -366,7 +367,6 @@ class ChatModelSwitchDelegateTest {
// Switch to model with fast capability // Switch to model with fast capability
delegate.sendSlashModel("openai", "gpt-4o") delegate.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle() advanceUntilIdle()
sentMethods.clear()
sentParams.clear() sentParams.clear()
assertTrue(uiState.value.currentModelCapabilities?.fast == true) assertTrue(uiState.value.currentModelCapabilities?.fast == true)
@@ -377,10 +377,10 @@ class ChatModelSwitchDelegateTest {
advanceUntilIdle() advanceUntilIdle()
assertTrue(uiState.value.isFastModeChanging) assertTrue(uiState.value.isFastModeChanging)
assertFalse(uiState.value.fastMode) // Still unconfirmed! assertFalse(uiState.value.fastMode) // Still unconfirmed!
assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods) assertEquals(1, sentParams.size)
assertEquals("fast", sentParams.first()["key"]) assertEquals("fast", sentParams.first().key)
assertEquals("fast", sentParams.first()["value"]) assertEquals("fast", sentParams.first().value)
assertEquals("runtime-1", sentParams.first()["session_id"]) assertEquals("runtime-1", sentParams.first().sessionId)
// Backend acknowledges // Backend acknowledges
delegate.handleConfigSetResult("req-1", mapOf("key" to "fast", "value" to "fast")) delegate.handleConfigSetResult("req-1", mapOf("key" to "fast", "value" to "fast"))
@@ -391,7 +391,7 @@ class ChatModelSwitchDelegateTest {
delegate.toggleFastMode() delegate.toggleFastMode()
advanceUntilIdle() advanceUntilIdle()
assertTrue(uiState.value.isFastModeChanging) assertTrue(uiState.value.isFastModeChanging)
assertEquals("normal", sentParams.last()["value"]) assertEquals("normal", sentParams.last().value)
delegate.handleConfigSetResult("req-2", mapOf("key" to "fast", "value" to "normal")) delegate.handleConfigSetResult("req-2", mapOf("key" to "fast", "value" to "normal"))
assertFalse(uiState.value.fastMode) assertFalse(uiState.value.fastMode)
@@ -405,7 +405,6 @@ class ChatModelSwitchDelegateTest {
advanceUntilIdle() advanceUntilIdle()
delegate.sendSlashModel("openai", "gpt-4o") delegate.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle() advanceUntilIdle()
sentMethods.clear()
sentParams.clear() sentParams.clear()
assertTrue(uiState.value.currentModelCapabilities?.fast == true) assertTrue(uiState.value.currentModelCapabilities?.fast == true)
@@ -420,10 +419,10 @@ class ChatModelSwitchDelegateTest {
assertEquals(false, uiState.value.currentModelCapabilities?.fast) assertEquals(false, uiState.value.currentModelCapabilities?.fast)
// Further toggle attempts are no-ops // Further toggle attempts are no-ops
val sentCount = sentMethods.size val sentCount = sentParams.size
delegate.toggleFastMode() delegate.toggleFastMode()
advanceUntilIdle() advanceUntilIdle()
assertEquals(sentCount, sentMethods.size) assertEquals(sentCount, sentParams.size)
} }
@Test @Test
@@ -470,9 +469,8 @@ class ChatModelSwitchDelegateTest {
@Test @Test
fun setReasoningLevel_awaitsRequest_updatesOnAck_andPreventsConcurrentPick() = fun setReasoningLevel_awaitsRequest_updatesOnAck_andPreventsConcurrentPick() =
testScope.runTest { testScope.runTest {
val gate = CompletableDeferred<Any?>() val gate = CompletableDeferred<JsonElement>()
val reqMethods = mutableListOf<String>() val reqParams = mutableListOf<ConfigSetParams>()
val reqParams = mutableListOf<Map<String, Any>>()
val customDelegate = val customDelegate =
ChatModelSwitchDelegate( ChatModelSwitchDelegate(
@@ -480,14 +478,13 @@ class ChatModelSwitchDelegateTest {
ioDispatcher = testDispatcher, ioDispatcher = testDispatcher,
uiState = uiState, uiState = uiState,
runtimeSessionId = { runtimeId }, runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> }, wsSend = { _, _ -> },
trackRequest = { _, _ -> }, trackRequest = { _, _ -> },
addAssistantMessage = {}, addAssistantMessage = {},
handleSlashCommand = {}, handleSlashCommand = {},
fetchContextUsage = {}, fetchContextUsage = {},
dataScopeFlow = dataScope, dataScopeFlow = dataScope,
wsRequest = { method, params -> wsRequest = { params ->
reqMethods.add(method)
reqParams.add(params) reqParams.add(params)
gate.await() gate.await()
}, },
@@ -503,14 +500,15 @@ class ChatModelSwitchDelegateTest {
assertEquals("ultra", uiState.value.reasoningLevel) assertEquals("ultra", uiState.value.reasoningLevel)
assertEquals("high", uiState.value.pendingReasoningLevel) assertEquals("high", uiState.value.pendingReasoningLevel)
assertNull(uiState.value.reasoningWireLevel) assertNull(uiState.value.reasoningWireLevel)
assertEquals(listOf(WsMethods.CONFIG_SET), reqMethods) assertEquals(1, reqParams.size)
assertEquals("session", reqParams.first()["scope"]) assertEquals("reasoning", reqParams.first().key)
assertEquals("high", reqParams.first()["value"]) assertEquals("session", reqParams.first().scope)
assertEquals("high", reqParams.first().value)
// Second pick while in-flight is rejected (serialized) // Second pick while in-flight is rejected (serialized)
customDelegate.setReasoningLevel("low") customDelegate.setReasoningLevel("low")
runCurrent() runCurrent()
assertEquals(1, reqMethods.size) assertEquals(1, reqParams.size)
// Simulate session.info winning the race and publishing the fresh // Simulate session.info winning the race and publishing the fresh
// authoritative wire before the RPC ACK resumes the delegate. // authoritative wire before the RPC ACK resumes the delegate.
@@ -540,13 +538,13 @@ class ChatModelSwitchDelegateTest {
ioDispatcher = testDispatcher, ioDispatcher = testDispatcher,
uiState = uiState, uiState = uiState,
runtimeSessionId = { runtimeId }, runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> }, wsSend = { _, _ -> },
trackRequest = { _, _ -> }, trackRequest = { _, _ -> },
addAssistantMessage = {}, addAssistantMessage = {},
handleSlashCommand = {}, handleSlashCommand = {},
fetchContextUsage = {}, fetchContextUsage = {},
dataScopeFlow = dataScope, dataScopeFlow = dataScope,
wsRequest = { _, _ -> throw RuntimeException("4002 unknown reasoning value") }, wsRequest = { throw RuntimeException("4002 unknown reasoning value") },
) )
customDelegate.setReasoningLevel("ultra") customDelegate.setReasoningLevel("ultra")
@@ -560,7 +558,7 @@ class ChatModelSwitchDelegateTest {
@Test @Test
fun setReasoningLevel_whenRuntimeSessionNullOrBlank_doesNotSend() = fun setReasoningLevel_whenRuntimeSessionNullOrBlank_doesNotSend() =
testScope.runTest { testScope.runTest {
val reqMethods = mutableListOf<String>() val reqParams = mutableListOf<ConfigSetParams>()
runtimeId = null runtimeId = null
val customDelegate = val customDelegate =
@@ -569,22 +567,22 @@ class ChatModelSwitchDelegateTest {
ioDispatcher = testDispatcher, ioDispatcher = testDispatcher,
uiState = uiState, uiState = uiState,
runtimeSessionId = { runtimeId }, runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> }, wsSend = { _, _ -> },
trackRequest = { _, _ -> }, trackRequest = { _, _ -> },
addAssistantMessage = {}, addAssistantMessage = {},
handleSlashCommand = {}, handleSlashCommand = {},
fetchContextUsage = {}, fetchContextUsage = {},
dataScopeFlow = dataScope, dataScopeFlow = dataScope,
wsRequest = { method, _ -> wsRequest = { params ->
reqMethods.add(method) reqParams.add(params)
null JsonNull
}, },
) )
customDelegate.setReasoningLevel("high") customDelegate.setReasoningLevel("high")
advanceUntilIdle() advanceUntilIdle()
assertTrue(reqMethods.isEmpty()) assertTrue(reqParams.isEmpty())
assertNull(uiState.value.pendingReasoningLevel) assertNull(uiState.value.pendingReasoningLevel)
assertNull(uiState.value.reasoningLevel) assertNull(uiState.value.reasoningLevel)
} }
@@ -592,20 +590,20 @@ class ChatModelSwitchDelegateTest {
@Test @Test
fun setReasoningLevel_staleScopeOrReset_dropsLateAck() = fun setReasoningLevel_staleScopeOrReset_dropsLateAck() =
testScope.runTest { testScope.runTest {
val gate = CompletableDeferred<Any?>() val gate = CompletableDeferred<JsonElement>()
val customDelegate = val customDelegate =
ChatModelSwitchDelegate( ChatModelSwitchDelegate(
scope = testScope, scope = testScope,
ioDispatcher = testDispatcher, ioDispatcher = testDispatcher,
uiState = uiState, uiState = uiState,
runtimeSessionId = { runtimeId }, runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> }, wsSend = { _, _ -> },
trackRequest = { _, _ -> }, trackRequest = { _, _ -> },
addAssistantMessage = {}, addAssistantMessage = {},
handleSlashCommand = {}, handleSlashCommand = {},
fetchContextUsage = {}, fetchContextUsage = {},
dataScopeFlow = dataScope, dataScopeFlow = dataScope,
wsRequest = { _, _ -> gate.await() }, wsRequest = { gate.await() },
) )
customDelegate.setReasoningLevel("high") customDelegate.setReasoningLevel("high")
@@ -617,7 +615,12 @@ class ChatModelSwitchDelegateTest {
assertNull(uiState.value.pendingReasoningLevel) assertNull(uiState.value.pendingReasoningLevel)
// Late RPC response arrives // Late RPC response arrives
gate.complete(mapOf("key" to "reasoning", "value" to "high")) gate.complete(
kotlinx.serialization.json.buildJsonObject {
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
put("value", kotlinx.serialization.json.JsonPrimitive("high"))
},
)
advanceUntilIdle() advanceUntilIdle()
assertNull(uiState.value.reasoningLevel) assertNull(uiState.value.reasoningLevel)
@@ -35,6 +35,8 @@ import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.ModelCatalogStore import com.m57.hermescontrol.data.ws.ModelCatalogStore
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.ConfigSetParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.notification.TurnCorrelationTracker import com.m57.hermescontrol.notification.TurnCorrelationTracker
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
@@ -1295,15 +1297,9 @@ class ChatViewModelTest {
// commands). NOT command.dispatch (4018s on /model), NOT prompt.submit // commands). NOT command.dispatch (4018s on /model), NOT prompt.submit
// (LLM would treat it as text). Capture the config.set params. // (LLM would treat it as text). Capture the config.set params.
val modelCalls = mutableListOf<Triple<String, String, String>>() val modelCalls = mutableListOf<Triple<String, String, String>>()
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { every { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) } answers {
val params = arg<Map<String, Any>>(1) val params = arg<ConfigSetParams>(1)
modelCalls.add( modelCalls.add(Triple(params.key, params.value, params.sessionId.orEmpty()))
Triple(
params["key"] as String,
params["value"] as String,
params["session_id"] as String,
),
)
"req-cfg-${modelCalls.size}" "req-cfg-${modelCalls.size}"
} }
@@ -1315,7 +1311,7 @@ class ChatViewModelTest {
"openai/gpt-4o", "openai/gpt-4o",
viewModel.uiState.value.currentSessionModel, viewModel.uiState.value.currentSessionModel,
) )
verify { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } verify { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) }
val call = modelCalls.firstOrNull { it.first == "model" } val call = modelCalls.firstOrNull { it.first == "model" }
assertNotNull("selection must route through config.set key=model", call) assertNotNull("selection must route through config.set key=model", call)
assertEquals("gpt-4o --provider openai --session", call!!.second) assertEquals("gpt-4o --provider openai --session", call!!.second)
@@ -1340,7 +1336,7 @@ class ChatViewModelTest {
// (key="model"), which the gateway routes to _apply_model_switch. NOT // (key="model"), which the gateway routes to _apply_model_switch. NOT
// command.dispatch (4018s on /model) and NOT prompt.submit (LLM would // command.dispatch (4018s on /model) and NOT prompt.submit (LLM would
// treat it as text). // treat it as text).
verify { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } verify { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) }
} }
@Test @Test
@@ -1353,15 +1349,9 @@ class ChatViewModelTest {
// slash prefix must be stripped, or parse_model_flags on the // slash prefix must be stripped, or parse_model_flags on the
// backend won't recognize it and the hot-swap silently fails. // backend won't recognize it and the hot-swap silently fails.
val modelCalls = mutableListOf<Triple<String, String, String>>() val modelCalls = mutableListOf<Triple<String, String, String>>()
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { every { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) } answers {
val params = arg<Map<String, Any>>(1) val params = arg<ConfigSetParams>(1)
modelCalls.add( modelCalls.add(Triple(params.key, params.value, params.sessionId.orEmpty()))
Triple(
params["key"] as String,
params["value"] as String,
params["session_id"] as String,
),
)
"req-cfg-ci-${modelCalls.size}" "req-cfg-ci-${modelCalls.size}"
} }
@@ -1388,9 +1378,9 @@ class ChatViewModelTest {
val (viewModel, sessionId) = createViewModelWithSession() val (viewModel, sessionId) = createViewModelWithSession()
var lastReqId = "" var lastReqId = ""
val capturedParams = mutableListOf<Map<String, Any>>() val capturedParams = mutableListOf<ConfigSetParams>()
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { every { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) } answers {
val params = arg<Map<String, Any>>(1) val params = arg<ConfigSetParams>(1)
capturedParams.add(params) capturedParams.add(params)
val id = "req-confirm-${capturedParams.size}" val id = "req-confirm-${capturedParams.size}"
lastReqId = id lastReqId = id
@@ -1403,7 +1393,7 @@ class ChatViewModelTest {
advanceUntilIdle() advanceUntilIdle()
assertEquals(1, capturedParams.size) assertEquals(1, capturedParams.size)
assertNull(capturedParams[0]["confirm_expensive_model"]) assertNull(capturedParams[0].confirmExpensiveModel)
// Backend returns confirm_required = true // Backend returns confirm_required = true
mockEventsFlow.emit( mockEventsFlow.emit(
@@ -1429,12 +1419,12 @@ class ChatViewModelTest {
assertNull(viewModel.uiState.value.modelSwitchConfirmMessage) assertNull(viewModel.uiState.value.modelSwitchConfirmMessage)
assertEquals(2, capturedParams.size) assertEquals(2, capturedParams.size)
assertEquals(true, capturedParams[1]["confirm_expensive_model"]) assertEquals(true, capturedParams[1].confirmExpensiveModel)
assertEquals( assertEquals(
"muse-spark-1.3-contributor-free --provider opencode-free --session", "muse-spark-1.3-contributor-free --provider opencode-free --session",
capturedParams[1]["value"], capturedParams[1].value,
) )
assertEquals(sessionId, capturedParams[1]["session_id"]) assertEquals(sessionId, capturedParams[1].sessionId)
} }
@Test @Test
@@ -1443,7 +1433,7 @@ class ChatViewModelTest {
val (viewModel, _) = createViewModelWithSession() val (viewModel, _) = createViewModelWithSession()
var lastReqId = "" var lastReqId = ""
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { every { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) } answers {
val id = "req-cfg-dismiss" val id = "req-cfg-dismiss"
lastReqId = id lastReqId = id
arg<((String) -> Unit)?>(2)?.invoke(id) arg<((String) -> Unit)?>(2)?.invoke(id)
@@ -12,6 +12,9 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
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.ConfigGetParams
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.coEvery import io.mockk.coEvery
@@ -21,6 +24,7 @@ import io.mockk.mockkObject
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.slot import io.mockk.slot
import io.mockk.unmockkAll import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -34,6 +38,7 @@ import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -271,35 +276,27 @@ class SlashCommandDispatchRpcTest {
runTest { runTest {
val (vm, sessionId) = createViewModelWithSession() val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>() val setCalls = mutableListOf<ConfigSetParams>()
val paramsCalls = mutableListOf<Map<String, Any>>() coEvery {
every { HermesWsClient.call(RpcMethods.CONFIG_SET, capture(setCalls), any(), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers { } answers {
val result = kotlinx.serialization.json.buildJsonObject {
if (arg<String>(0) == WsMethods.CONFIG_SET) { put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
kotlinx.serialization.json.buildJsonObject { put("value", kotlinx.serialization.json.JsonPrimitive("high"))
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning")) }
put("value", kotlinx.serialization.json.JsonPrimitive("high"))
}
} else {
kotlinx.serialization.json.JsonNull
}
CompletableDeferred<Any?>(result)
} }
vm.sendMessage("/reasoning high --global") vm.sendMessage("/reasoning high --global")
advanceUntilIdle() advanceUntilIdle()
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_SET) assertEquals("expected exactly one config.set, got $setCalls", 1, setCalls.size)
assertTrue("expected CONFIG_SET, got $methodCalls", configIndex >= 0) val params = setCalls.single()
val params = paramsCalls[configIndex] assertEquals("reasoning", params.key)
assertEquals("reasoning", params["key"]) assertEquals("high", params.value)
assertEquals("high", params["value"]) assertEquals(sessionId, params.sessionId)
assertEquals(sessionId, params["session_id"]) assertEquals("global", params.scope)
assertEquals("global", params["scope"])
assertEquals("high", vm.uiState.value.reasoningLevel) assertEquals("high", vm.uiState.value.reasoningLevel)
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls) verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
} }
@Test @Test
@@ -307,33 +304,26 @@ class SlashCommandDispatchRpcTest {
runTest { runTest {
val (vm, sessionId) = createViewModelWithSession() val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>() val setCalls = mutableListOf<ConfigSetParams>()
val paramsCalls = mutableListOf<Map<String, Any>>() coEvery {
every { HermesWsClient.call(RpcMethods.CONFIG_SET, capture(setCalls), any(), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers { } answers {
val result = kotlinx.serialization.json.buildJsonObject {
if (arg<String>(0) == WsMethods.CONFIG_SET) { put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
kotlinx.serialization.json.buildJsonObject { put("value", kotlinx.serialization.json.JsonPrimitive("show"))
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning")) }
put("value", kotlinx.serialization.json.JsonPrimitive("show"))
}
} else {
kotlinx.serialization.json.JsonNull
}
CompletableDeferred<Any?>(result)
} }
vm.sendMessage("/reasoning show") vm.sendMessage("/reasoning show")
advanceUntilIdle() advanceUntilIdle()
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_SET) assertEquals("expected exactly one config.set, got $setCalls", 1, setCalls.size)
assertTrue("expected CONFIG_SET, got $methodCalls", configIndex >= 0) val params = setCalls.single()
val params = paramsCalls[configIndex] assertEquals("reasoning", params.key)
assertEquals("reasoning", params["key"]) assertEquals("show", params.value)
assertEquals("show", params["value"]) assertEquals(sessionId, params.sessionId)
assertEquals(sessionId, params["session_id"]) assertNull(params.scope)
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls) verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
assertEquals( assertEquals(
"reasoning: show", "reasoning: show",
vm.uiState.value.messages vm.uiState.value.messages
@@ -347,32 +337,24 @@ class SlashCommandDispatchRpcTest {
runTest { runTest {
val (vm, sessionId) = createViewModelWithSession() val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>() val getCalls = mutableListOf<ConfigGetParams>()
val paramsCalls = mutableListOf<Map<String, Any>>() coEvery {
every { HermesWsClient.call(RpcMethods.CONFIG_GET, capture(getCalls), any(), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers { } answers {
val result = kotlinx.serialization.json.buildJsonObject {
if (arg<String>(0) == WsMethods.CONFIG_GET) { put("value", kotlinx.serialization.json.JsonPrimitive("ultra"))
kotlinx.serialization.json.buildJsonObject { put("display", kotlinx.serialization.json.JsonPrimitive("hide"))
put("value", kotlinx.serialization.json.JsonPrimitive("ultra")) }
put("display", kotlinx.serialization.json.JsonPrimitive("hide"))
}
} else {
kotlinx.serialization.json.JsonNull
}
CompletableDeferred<Any?>(result)
} }
vm.sendMessage("/reasoning") vm.sendMessage("/reasoning")
advanceUntilIdle() advanceUntilIdle()
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_GET) assertEquals("expected exactly one config.get, got $getCalls", 1, getCalls.size)
assertTrue("expected CONFIG_GET, got $methodCalls", configIndex >= 0) val params = getCalls.single()
val params = paramsCalls[configIndex] assertEquals("reasoning", params.key)
assertEquals("reasoning", params["key"]) assertEquals(sessionId, params.sessionId)
assertEquals(sessionId, params["session_id"]) verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
assertEquals( assertEquals(
"reasoning: ultra · display hide", "reasoning: ultra · display hide",
vm.uiState.value.messages vm.uiState.value.messages