diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt new file mode 100644 index 00000000..44e2996c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt @@ -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, + private val runtimeSessionId: () -> String?, + private val wsSend: (method: String, params: Map, 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 = + { refresh -> safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh) } }, + private val getPinnedModels: () -> List = { AuthManager.getPinnedModels() }, + private val savePinnedModels: (List) -> Unit = { AuthManager.savePinnedModels(it) }, +) { + val pendingModelSwitchRequests = ConcurrentHashMap() + var activeModelSwitchConfirmation: ActiveModelSwitch? = null + var optimisticPreviousModel: String? = null + var cachedModelOptions: List = 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( + "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 + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt new file mode 100644 index 00000000..346af7c8 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt @@ -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() + private val sentParams = mutableListOf>() + private val slashCommands = mutableListOf() + private val assistantMessages = mutableListOf() + 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) + } +}