refactor(chat): extract model switch delegate with tests

This commit is contained in:
Hy4ri committed 2026-09-11 00:03:46 +03:00
1 parent 9d35bf7cdc
commit 280a03ea23
2 files changed
+427

No files matched your search

@@ -0,0 +1,286 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ModelCapabilities
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* Tracks an in-flight `config.set key=model` RPC and the model it would replace
* so confirmation dialog dismiss can roll back optimistically.
*/
data class ActiveModelSwitch(
val spec: String,
val previousModel: String?,
)
/**
* Owns model picker loading/caching, in-session model switches, model capabilities,
* pinned model toggling, and expensive model switch confirmation dialogs.
*
* Extracted behavior-preservingly from [ChatViewModel] (issue #589).
*/
class ChatModelSwitchDelegate(
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher,
private val uiState: MutableStateFlow<ChatUiState>,
private val runtimeSessionId: () -> String?,
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
private val trackRequest: (id: String, method: String) -> Unit,
private val addAssistantMessage: (text: String) -> Unit,
private val handleSlashCommand: (command: String) -> Unit,
private val fetchContextUsage: () -> Unit,
private val getModelOptionsCall: suspend (
refresh: Boolean,
) -> NetworkResult<com.m57.hermescontrol.data.model.ModelOptionsResponse> =
{ refresh -> safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh) } },
private val getPinnedModels: () -> List<PinnedModel> = { AuthManager.getPinnedModels() },
private val savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) },
) {
val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
var activeModelSwitchConfirmation: ActiveModelSwitch? = null
var optimisticPreviousModel: String? = null
var cachedModelOptions: List<ModelProvider> = emptyList()
fun isModelPickerCommand(command: String): Boolean {
val trimmed = command.trim()
if (!trimmed.startsWith("/", ignoreCase = true)) return false
val body = trimmed.removePrefix("/").trimStart()
return body.equals("model", ignoreCase = true) ||
(
body.startsWith("model ", ignoreCase = true) &&
body.substringAfter("model").trim().isEmpty()
)
}
fun preloadModelOptions() {
scope.launch(ioDispatcher) {
val result = getModelOptionsCall(false)
if (result is NetworkResult.Success) {
cachedModelOptions = result.data.providers.orEmpty()
uiState.update { it.copy(modelPickerPinned = getPinnedModels()) }
syncCurrentModelCapabilities()
}
}
}
fun openModelPicker() {
val hasCached = cachedModelOptions.isNotEmpty()
uiState.update {
it.copy(
showModelPicker = true,
modelPickerProviders = if (hasCached) cachedModelOptions else emptyList(),
modelPickerPinned = getPinnedModels(),
modelPickerLoading = !hasCached,
)
}
if (!hasCached) {
refreshModelOptions()
}
}
fun refreshModelOptions() {
uiState.update { it.copy(modelPickerLoading = true) }
scope.launch(ioDispatcher) {
val result = getModelOptionsCall(true)
when (result) {
is NetworkResult.Success -> {
cachedModelOptions = result.data.providers.orEmpty()
uiState.update {
it.copy(
modelPickerProviders = cachedModelOptions,
modelPickerLoading = false,
)
}
syncCurrentModelCapabilities()
}
is NetworkResult.Failure -> {
uiState.update {
it.copy(
modelPickerLoading = false,
errorMessage = "Failed to load models: ${result.error.message}",
)
}
}
}
}
}
fun closeModelPicker() {
uiState.update { it.copy(showModelPicker = false, modelPickerLoading = false) }
}
fun togglePinModel(
providerSlug: String,
modelName: String,
) {
val currentPinned = getPinnedModels().toMutableList()
val target = PinnedModel(providerSlug, modelName)
if (currentPinned.contains(target)) {
currentPinned.remove(target)
} else {
currentPinned.add(target)
}
savePinnedModels(currentPinned)
uiState.update { it.copy(modelPickerPinned = currentPinned) }
}
fun sendSlashModel(
provider: String,
model: String,
) {
optimisticPreviousModel = uiState.value.currentSessionModel
uiState.update {
it.copy(
showModelPicker = false,
modelPickerLoading = false,
currentSessionModel = "$provider/$model",
)
}
syncCurrentModelCapabilities()
fetchContextUsage()
handleSlashCommand("/model $model --provider $provider --session")
}
fun handleModelSwitch(
command: String,
confirmExpensive: Boolean = false,
) {
val sessionId = runtimeSessionId()
if (sessionId == null) {
addAssistantMessage("No active session. Use `/new` to create one.")
return
}
val spec =
if (command.startsWith("/model", ignoreCase = true)) {
command.substring(6).trim()
} else {
command.trim()
}
val previousModel = optimisticPreviousModel ?: uiState.value.currentSessionModel
optimisticPreviousModel = null
val params =
mutableMapOf<String, Any>(
"key" to "model",
"value" to spec,
"session_id" to sessionId,
)
if (confirmExpensive) {
params["confirm_expensive_model"] = true
}
scope.launch(ioDispatcher) {
wsSend(
WsMethods.CONFIG_SET,
params,
) { id ->
trackRequest(id, WsMethods.CONFIG_SET)
pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel)
}
}
}
fun handleConfigSetResult(
id: String,
result: Any?,
) {
val pending = pendingModelSwitchRequests.remove(id)
val map = result as? Map<*, *> ?: return
val key = map["key"] as? String
if (key == "model") {
val confirmRequired = map["confirm_required"] as? Boolean ?: false
if (confirmRequired) {
val confirmMessage =
(map["confirm_message"] as? String)
?: (map["warning"] as? String)
?: "This model requires confirmation to switch. Continue?"
activeModelSwitchConfirmation = pending
uiState.update {
it.copy(modelSwitchConfirmMessage = confirmMessage)
}
}
}
}
fun dismissModelSwitchConfirm() {
val pending = activeModelSwitchConfirmation
activeModelSwitchConfirmation = null
uiState.update {
it.copy(
modelSwitchConfirmMessage = null,
currentSessionModel = pending?.previousModel ?: it.currentSessionModel,
)
}
syncCurrentModelCapabilities()
fetchContextUsage()
}
fun confirmModelSwitchExpensive() {
val pending = activeModelSwitchConfirmation
activeModelSwitchConfirmation = null
uiState.update { it.copy(modelSwitchConfirmMessage = null) }
if (pending != null) {
handleModelSwitch(pending.spec, confirmExpensive = true)
}
}
fun setReasoningLevel(level: String?) {
uiState.update { it.copy(reasoningLevel = level) }
val sessionId = runtimeSessionId() ?: return
if (level == null) return
scope.launch(ioDispatcher) {
wsSend(
WsMethods.CONFIG_SET,
mapOf(
"key" to "reasoning",
"value" to level,
"session_id" to sessionId,
),
) { id -> trackRequest(id, WsMethods.CONFIG_SET) }
}
}
fun getModelCapabilities(
providerSlug: String,
modelName: String,
): ModelCapabilities? = cachedModelOptions.find { it.slug == providerSlug }?.capabilities?.get(modelName)
fun getCurrentModelCapabilities(): ModelCapabilities? {
val label = uiState.value.currentSessionModel ?: return null
val idx = label.indexOf('/')
if (idx <= 0) return null
val provider = label.substring(0, idx)
val model = label.substring(idx + 1)
return getModelCapabilities(provider, model)
}
fun syncCurrentModelCapabilities() {
val label = uiState.value.currentSessionModel
val caps =
if (label == null) {
null
} else {
val idx = label.indexOf('/')
if (idx <= 0) {
null
} else {
val provider = label.substring(0, idx)
val model = label.substring(idx + 1)
cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model)
}
}
uiState.update { state ->
if (state.currentModelCapabilities != caps) state.copy(currentModelCapabilities = caps) else state
}
}
}
@@ -0,0 +1,141 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.ModelCapabilities
import com.m57.hermescontrol.data.model.ModelOptionsResponse
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChatModelSwitchDelegateTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val uiState =
MutableStateFlow(ChatUiState(currentSessionId = "sess-1", currentSessionModel = "anthropic/claude-3"))
private var runtimeId: String? = "runtime-1"
private val sentMethods = mutableListOf<String>()
private val sentParams = mutableListOf<Map<String, Any>>()
private val slashCommands = mutableListOf<String>()
private val assistantMessages = mutableListOf<String>()
private var contextRefetched = 0
private var pinnedList = mutableListOf(PinnedModel("anthropic", "claude-3"))
private val fakeResponse =
ModelOptionsResponse(
providers =
listOf(
ModelProvider(
slug = "openai",
name = "OpenAI",
models = listOf("gpt-4o"),
capabilities = mapOf("gpt-4o" to ModelCapabilities(reasoning = true)),
),
),
)
private val delegate =
ChatModelSwitchDelegate(
scope = testScope,
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { method, params, onSent ->
sentMethods.add(method)
sentParams.add(params)
onSent?.invoke("req-${sentMethods.size}")
},
trackRequest = { _, _ -> },
addAssistantMessage = { assistantMessages.add(it) },
handleSlashCommand = { slashCommands.add(it) },
fetchContextUsage = { contextRefetched++ },
getModelOptionsCall = { NetworkResult.Success(fakeResponse) },
getPinnedModels = { pinnedList },
savePinnedModels = { pinnedList = it.toMutableList() },
)
@Test
fun isModelPickerCommand_identifiesBareModelCommandOnly() {
assertTrue(delegate.isModelPickerCommand("/model"))
assertTrue(delegate.isModelPickerCommand("/MODEL "))
assertTrue(delegate.isModelPickerCommand(" /model "))
assertFalse(delegate.isModelPickerCommand("/model openai/gpt-4o"))
assertFalse(delegate.isModelPickerCommand("hello /model"))
}
@Test
fun openModelPicker_andPreload_populatesProvidersAndPinned() =
testScope.runTest {
delegate.preloadModelOptions()
advanceUntilIdle()
delegate.openModelPicker()
assertTrue(uiState.value.showModelPicker)
assertEquals(1, uiState.value.modelPickerProviders.size)
assertFalse(uiState.value.modelPickerLoading)
}
@Test
fun sendSlashModel_optimisticallyUpdatesModelAndDispatchesSlash() {
delegate.sendSlashModel("openai", "gpt-4o")
assertEquals("openai/gpt-4o", uiState.value.currentSessionModel)
assertFalse(uiState.value.showModelPicker)
assertEquals(listOf("/model gpt-4o --provider openai --session"), slashCommands)
assertEquals(1, contextRefetched)
}
@Test
fun handleModelSwitch_stripsLeadingSlashModel_andHandlesConfirmation() =
testScope.runTest {
delegate.handleModelSwitch("/MODEL gpt-4o --provider openai --session")
advanceUntilIdle()
assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods)
assertEquals("gpt-4o --provider openai --session", sentParams.first()["value"])
delegate.handleConfigSetResult(
id = "req-1",
result =
mapOf(
"key" to "model",
"confirm_required" to true,
"confirm_message" to "High token cost ahead",
),
)
assertEquals("High token cost ahead", uiState.value.modelSwitchConfirmMessage)
delegate.confirmModelSwitchExpensive()
advanceUntilIdle()
assertEquals(2, sentMethods.size)
assertEquals(true, sentParams[1]["confirm_expensive_model"])
}
@Test
fun dismissModelSwitchConfirm_revertsToPreviousModel() =
testScope.runTest {
delegate.sendSlashModel("opencode-free", "muse-spark-1.3-contributor-free")
advanceUntilIdle()
// In real ChatViewModel, handleSlashCommand routes to handleModelSwitch:
delegate.handleModelSwitch("/model muse-spark-1.3-contributor-free --provider opencode-free --session")
advanceUntilIdle()
delegate.handleConfigSetResult(
id = "req-1",
result = mapOf("key" to "model", "confirm_required" to true),
)
delegate.dismissModelSwitchConfirm()
assertNull(uiState.value.modelSwitchConfirmMessage)
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
}
}