diff --git a/app/src/androidTest/java/com/yage/opencode_client/ReadToolCardIntegrationTest.kt b/app/src/androidTest/java/com/yage/opencode_client/ReadToolCardIntegrationTest.kt index 90794881..bc96643f 100644 --- a/app/src/androidTest/java/com/yage/opencode_client/ReadToolCardIntegrationTest.kt +++ b/app/src/androidTest/java/com/yage/opencode_client/ReadToolCardIntegrationTest.kt @@ -145,8 +145,10 @@ class ReadToolCardIntegrationTest { messageLimit = 200, repository = repository, workspaceDirectory = null, + completedTurnActivities = emptyList(), onLoadMore = {}, onFileClick = {}, + onMarkdownLinkClick = {}, onForkFromMessage = {}, onEditFromMessage = {} ) diff --git a/app/src/main/java/com/yage/opencode_client/MainActivity.kt b/app/src/main/java/com/yage/opencode_client/MainActivity.kt index c9cbcfdc..b1e11a77 100644 --- a/app/src/main/java/com/yage/opencode_client/MainActivity.kt +++ b/app/src/main/java/com/yage/opencode_client/MainActivity.kt @@ -365,11 +365,11 @@ private fun TabletLayout(viewModel: MainViewModel) { val filesWeight = if (sessionsPaneCollapsed) 0.5f else 0.375f val chatWeight = if (sessionsPaneCollapsed) 0.5f else 0.375f - Row( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.statusBars) - ) { + Row( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars) + ) { // Left panel: Session list or Settings — 25% when expanded. if (!sessionsPaneCollapsed) { Column( @@ -446,9 +446,9 @@ private fun TabletLayout(viewModel: MainViewModel) { Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = stringResource(R.string.sessions_show) ) - } - } - } + } + } + } } } } diff --git a/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt b/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt index 97023d8a..572218b4 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt @@ -70,7 +70,9 @@ data class AppState( val isLoadingMessages: Boolean = false, val agents: List = emptyList(), val selectedAgentName: String = "build", - val selectedModelIndex: Int = 2, + val selectedModel: Message.ModelInfo? = null, + val availableModels: List = emptyList(), + val providerModelsIndex: Map = emptyMap(), val providers: ProvidersResponse? = null, val pendingPermissions: List = emptyList(), val pendingQuestions: List = emptyList(), @@ -110,20 +112,25 @@ data class AppState( val aiUsageError: String? = null ) { data class NfcPendingAction(val prompt: String, val autoSend: Boolean) - data class ModelOption(val displayName: String, val providerId: String, val modelId: String) { + data class ModelOption( + val displayName: String, + val providerId: String, + val modelId: String, + val modelLabel: String = displayName.substringBefore(" (") + ) { val shortName: String get() = when { - displayName == "DeepSeek V4 Flash" -> "DS-Flash" - displayName == "DeepSeek Local" -> "DS-L" - displayName == "DeepSeek V4 Pro" -> "DS-Pro" - displayName == "Ollama GLM 5.2" -> "OGLM-5.2" - displayName == "GPT-5.6 Sol Fast" -> "GPT-F" - displayName == "GPT-5.6 Terra Fast" -> "GPT-TF" - "Haiku" in displayName -> "Haiku" - "Gemini" in displayName -> "Gemini" - "GPT" in displayName -> "GPT" - "Grok" in displayName -> "Grok" - else -> displayName.split(" ").firstOrNull() ?: displayName + modelLabel == "GPT-5.6 Sol Pro" -> "GPT-P" + modelLabel == "GPT-5.6 Sol Fast" -> "GPT-F" + "Haiku" in modelLabel -> "Haiku" + "Sonnet" in modelLabel -> "Sonnet" + "Opus" in modelLabel -> "Opus" + "Gemini" in modelLabel -> "Gemini" + "GPT" in modelLabel -> modelLabel.split(" ").firstOrNull() ?: modelLabel + "Grok" in modelLabel -> "Grok" + "DeepSeek" in modelLabel -> "DeepSeek" + "GLM" in modelLabel -> "GLM" + else -> modelLabel.split(" ").firstOrNull() ?: modelLabel } } @@ -202,9 +209,9 @@ data class AppState( val error: String? = null, val themeMode: ThemeMode = ThemeMode.SYSTEM, val languageMode: LanguageMode = LanguageMode.SYSTEM, - val selectedModelIndex: Int = 2, + val selectedModelIndex: Int = 0, val selectedAgentName: String = "build", - val availableModels: List = ModelPresets.list, + val availableModels: List = emptyList(), val contextUsage: ContextUsage? = null, val agents: List = emptyList(), val providers: ProvidersResponse? = null, @@ -296,13 +303,12 @@ data class AppState( val visibleAgents: List get() = agents.filter { it.isVisible } - /** Curated model list (filtered like iOS), not the full API response. */ - val availableModels: List - get() = ModelPresets.list + val selectedModelIndex: Int + get() = modelIndexFor(availableModels, selectedModel) ?: 0 val selectedAIUsageQuota: AIUsageQuota? get() { - val provider = when (availableModels.getOrNull(selectedModelIndex)?.providerId) { + val provider = when (selectedModel?.providerId) { "openai" -> "codex" "zai-coding-plan" -> "glm" "ollama-cloud" -> "ollama" @@ -313,19 +319,6 @@ data class AppState( } } - private val providerModelsIndex: Map - get() = providers?.providers?.flatMap { provider -> - provider.models.flatMap { (modelKey, model) -> - listOfNotNull( - "${provider.id}/$modelKey" to model, - model.id.takeIf { it.isNotEmpty() }?.let { "${provider.id}/$it" to model }, - model.resolvedProviderId?.let { resolvedProvider -> - model.id.takeIf { it.isNotEmpty() }?.let { modelId -> "$resolvedProvider/$modelId" to model } - } - ) - } - }?.toMap() ?: emptyMap() - val contextUsage: ContextUsage? get() { val lastAssistant = messages.lastOrNull { it.info.isAssistant && tokenTotal(it.info.tokens) != null } @@ -408,6 +401,8 @@ class MainViewModel @Inject constructor( private var hostRuntimeJob = SupervisorJob(viewModelScope.coroutineContext[Job]) private val hostRuntimeScope: CoroutineScope get() = CoroutineScope(viewModelScope.coroutineContext + hostRuntimeJob) + private var modelCatalogRequestGeneration = 0L + private var configuredRepositoryTarget: RepositoryTarget? = null init { loadSettings() @@ -422,7 +417,7 @@ class MainViewModel @Inject constructor( settingsManager.serverUrl = url settingsManager.username = username settingsManager.password = password - repository.configure(url, username, password) + configureRepository(url, username, password) } fun getHostProfiles(): List = hostProfileStore.profiles() @@ -500,7 +495,7 @@ class MainViewModel @Inject constructor( hostRuntimeScope.launch { configureRepositoryForProfileAsync(profile) } return } - repository.configure(profile.serverUrl, profile.basicAuth?.username, password) + configureRepository(profile.serverUrl, profile.basicAuth?.username, password) } private suspend fun configureRepositoryForProfileAsync(profile: HostProfile): Boolean { @@ -528,10 +523,42 @@ class MainViewModel @Inject constructor( } } } - repository.configure(baseUrl, profile.basicAuth?.username, password) + configureRepository(baseUrl, profile.basicAuth?.username, password) return true } + private data class RepositoryTarget( + val baseUrl: String, + val username: String?, + val password: String? + ) + + /** + * Points the repository at [baseUrl], clearing the model catalog only when the + * effective target actually changes so same-profile reconnects (e.g. the + * lifecycle-driven health check) keep the loaded catalog and selection. + */ + private fun configureRepository(baseUrl: String, username: String?, password: String?) { + val target = RepositoryTarget(baseUrl, username, password) + if (configuredRepositoryTarget != target) { + configuredRepositoryTarget = target + resetModelCatalogForCurrentProfile() + } + repository.configure(baseUrl, username, password) + } + + private fun resetModelCatalogForCurrentProfile() { + modelCatalogRequestGeneration += 1 + _state.update { + it.copy( + providers = null, + availableModels = emptyList(), + providerModelsIndex = emptyMap(), + selectedModel = null + ) + } + } + fun getSavedConnectionSettings(): ConnectionFormSettings = ConnectionFormSettings( serverUrl = settingsManager.serverUrl, username = settingsManager.username ?: "", @@ -1032,6 +1059,9 @@ class MainViewModel @Inject constructor( sessionSendTimestamps = emptyMap(), agents = emptyList(), providers = null, + availableModels = emptyList(), + providerModelsIndex = emptyMap(), + selectedModel = null, filePathToShowInFiles = null, filePreviewOriginRoute = null, pendingNfcAction = null, @@ -1075,9 +1105,14 @@ class MainViewModel @Inject constructor( } private fun loadProviders() { - launchLoadProviders(hostRuntimeScope, repository, _state) { message, error -> - reportNonFatalIssue(TAG, message, error) - } + val requestGeneration = ++modelCatalogRequestGeneration + launchLoadProviders( + scope = hostRuntimeScope, + repository = repository, + state = _state, + settingsManager = settingsManager, + shouldApply = { requestGeneration == modelCatalogRequestGeneration } + ) { message, error -> reportNonFatalIssue(TAG, message, error) } } fun createSession(title: String? = null) { @@ -1263,10 +1298,16 @@ class MainViewModel @Inject constructor( } fun selectModel(index: Int) { - val clamped = index.coerceIn(0, ModelPresets.list.size - 1) - settingsManager.selectedModelIndex = clamped - _state.update { it.copy(selectedModelIndex = clamped) } - _state.value.currentSessionId?.let { settingsManager.setModelForSession(it, clamped) } + val models = _state.value.availableModels + if (models.isEmpty()) return + val clamped = index.coerceIn(0, models.size - 1) + val selected = models[clamped] + settingsManager.setSelectedModel(selected.providerId, selected.modelId) + val selectedModel = Message.ModelInfo(selected.providerId, selected.modelId) + _state.update { it.copy(selectedModel = selectedModel) } + _state.value.currentSessionId?.let { + settingsManager.setModelForSession(it, selected.providerId, selected.modelId) + } } fun setThemeMode(mode: ThemeMode) { diff --git a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelConnectionActions.kt b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelConnectionActions.kt index 893fc0f1..06e8fdd6 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelConnectionActions.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelConnectionActions.kt @@ -25,18 +25,11 @@ internal fun applySavedSettings( password = password ) - val savedModelIndex = settingsManager.selectedModelIndex - val clampedModelIndex = savedModelIndex.coerceIn(0, ModelPresets.list.size - 1) - if (clampedModelIndex != savedModelIndex) { - settingsManager.selectedModelIndex = clampedModelIndex - } - state.update { it.copy( currentSessionId = settingsManager.currentSessionId, hostProfiles = hostProfileStore.profiles(), currentHostProfileId = currentProfile.id, - selectedModelIndex = clampedModelIndex, selectedAgentName = settingsManager.selectedAgentName ?: "build", themeMode = settingsManager.themeMode, languageMode = settingsManager.languageMode diff --git a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt index b010ac21..926b2383 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt @@ -2,6 +2,7 @@ package com.yage.opencode_client.ui import com.yage.opencode_client.data.model.ComposerImageAttachment import com.yage.opencode_client.data.model.Message +import com.yage.opencode_client.data.model.ProvidersResponse import com.yage.opencode_client.data.repository.OpenCodeRepository import com.yage.opencode_client.util.SettingsManager import kotlinx.coroutines.CoroutineScope @@ -160,14 +161,22 @@ internal fun selectSessionState( settingsManager.currentSessionId = sessionId val restoredDraft = settingsManager.getDraftText(sessionId) - state.update { - it.copy( + state.update { current -> + val selectedModel = resolveSelectedModel( + models = current.availableModels, + providers = current.providers, + settingsManager = settingsManager, + sessionId = sessionId, + messages = emptyList() + ) + current.copy( currentSessionId = sessionId, messages = emptyList(), streamingPartTexts = emptyMap(), streamingReasoningPart = null, messageLimit = 30, - inputText = restoredDraft + inputText = restoredDraft, + selectedModel = selectedModel ) } } @@ -188,21 +197,24 @@ internal fun launchLoadMessages( .onSuccess { messages -> if (sessionId == state.value.currentSessionId) { val lastAssistant = messages.lastOrNull { it.info.isAssistant } - val inferredModelIndex = lastAssistant?.info?.resolvedModel?.let { model -> - ModelPresets.list.indexOfFirst { - it.providerId == model.providerId && it.modelId == model.modelId - }.takeIf { it >= 0 } - } val inferredAgentName = lastAssistant?.info?.agent - val modelIndex = settingsManager?.getModelForSession(sessionId) ?: inferredModelIndex val agentName = settingsManager?.getAgentForSession(sessionId) ?: inferredAgentName - state.update { - it.copy( + state.update { current -> + val selectedModel = settingsManager?.let { manager -> + resolveSelectedModel( + models = current.availableModels, + providers = current.providers, + settingsManager = manager, + sessionId = sessionId, + messages = messages + ) + } + current.copy( messages = messages, messageLimit = limit, isLoadingMessages = false, - selectedModelIndex = modelIndex ?: it.selectedModelIndex, - selectedAgentName = agentName ?: it.selectedAgentName + selectedModel = selectedModel, + selectedAgentName = agentName ?: current.selectedAgentName ) } onMessagesLoaded?.invoke() @@ -286,19 +298,69 @@ internal fun launchLoadProviders( scope: CoroutineScope, repository: OpenCodeRepository, state: MutableStateFlow, + settingsManager: SettingsManager, + shouldApply: () -> Boolean = { true }, onNonFatalError: (String, Throwable?) -> Unit ) { scope.launch { repository.getProviders() .onSuccess { providers -> - state.update { it.copy(providers = providers) } + if (!shouldApply()) return@onSuccess + state.update { current -> + val catalog = buildModelCatalog(providers) + val selectedModel = resolveSelectedModel( + models = catalog.options, + providers = providers, + settingsManager = settingsManager, + sessionId = current.currentSessionId, + messages = current.messages + ) + current.copy( + providers = providers, + availableModels = catalog.options, + providerModelsIndex = catalog.providerModelsIndex, + selectedModel = selectedModel + ) + } } .onFailure { error -> - onNonFatalError("Failed to load providers", error) + if (shouldApply()) { + onNonFatalError("Failed to load providers", error) + } } } } +internal fun resolveSelectedModel( + models: List, + providers: ProvidersResponse?, + settingsManager: SettingsManager, + sessionId: String?, + messages: List +): Message.ModelInfo? { + if (models.isEmpty()) return null + sessionId + ?.let { settingsManager.getModelSelectionForSession(it) } + ?.let { (providerId, modelId) -> modelInfoFor(models, providerId, modelId) } + ?.let { return it } + messages.lastOrNull { it.info.isAssistant }?.info?.resolvedModel + ?.let { modelInfoFor(models, it) } + ?.let { return it } + settingsManager.selectedModelSelection() + ?.let { (providerId, modelId) -> modelInfoFor(models, providerId, modelId) } + ?.let { return it } + providers?.default + ?.let { modelInfoFor(models, it.providerId, it.modelId) } + ?.let { return it } + sessionId + ?.let { settingsManager.getModelForSession(it) } + ?.let { legacyPresetIndexToModelInfo(models, it) } + ?.let { return it } + legacyPresetIndexToModelInfo(models, settingsManager.selectedModelIndex) + ?.let { return it } + return models.firstOrNull()?.let { Message.ModelInfo(it.providerId, it.modelId) } +} + internal fun launchCreateSession( scope: CoroutineScope, repository: OpenCodeRepository, @@ -422,11 +484,9 @@ internal fun launchDeleteSession( } internal fun buildSelectedModel(state: AppState): Message.ModelInfo? { - val selectedModel = state.availableModels.getOrNull(state.selectedModelIndex) - return selectedModel?.let { - Message.ModelInfo(it.providerId, it.modelId) - } ?: state.providers?.default?.let { - Message.ModelInfo(it.providerId, it.modelId) + modelInfoFor(state.availableModels, state.selectedModel)?.let { return it } + return state.providers?.default?.let { + modelInfoFor(state.availableModels, it.providerId, it.modelId) } } diff --git a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSupport.kt b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSupport.kt index 9904e2d3..8926d2ed 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSupport.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/MainViewModelSupport.kt @@ -6,6 +6,9 @@ import com.yage.opencode_client.data.model.QuestionRequest import com.yage.opencode_client.data.model.SSEEvent import com.yage.opencode_client.data.model.Session import com.yage.opencode_client.data.model.SessionStatus +import com.yage.opencode_client.data.model.Message +import com.yage.opencode_client.data.model.ProviderModel +import com.yage.opencode_client.data.model.ProvidersResponse import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive import java.security.MessageDigest @@ -47,7 +50,7 @@ internal fun sanitizeBearerToken(rawToken: String): String { .filterNot { ch -> ch.isWhitespace() || Character.getType(ch) == Character.FORMAT.toInt() || - ch == '' + ch == '\uFEFF' } } @@ -63,6 +66,94 @@ internal fun errorMessageOrFallback(throwable: Throwable?, fallback: String): St return if (message.isEmpty()) fallback else message } +internal fun modelIndexFor( + models: List, + providerId: String?, + modelId: String? +): Int? { + if (providerId.isNullOrBlank() || modelId.isNullOrBlank()) return null + return models.indexOfFirst { it.providerId == providerId && it.modelId == modelId } + .takeIf { it >= 0 } +} + +internal fun modelIndexFor( + models: List, + model: Message.ModelInfo? +): Int? = modelIndexFor(models, model?.providerId, model?.modelId) + +internal fun modelInfoFor( + models: List, + providerId: String?, + modelId: String? +): Message.ModelInfo? { + val index = modelIndexFor(models, providerId, modelId) ?: return null + val model = models[index] + return Message.ModelInfo(model.providerId, model.modelId) +} + +internal fun modelInfoFor( + models: List, + model: Message.ModelInfo? +): Message.ModelInfo? = modelInfoFor(models, model?.providerId, model?.modelId) + +internal data class ModelCatalog( + val options: List = emptyList(), + val providerModelsIndex: Map = emptyMap() +) + +internal fun buildModelCatalog(providers: ProvidersResponse?): ModelCatalog { + if (providers == null) return ModelCatalog() + val indexedModels = mutableMapOf() + val options = providers.providers.flatMap { provider -> + val providerLabel = provider.name?.takeIf { it.isNotBlank() } ?: provider.id + provider.models.mapNotNull { (modelKey, model) -> + val providerIds = listOf(provider.id, model.resolvedProviderId) + .filterNotNull() + .filter { it.isNotBlank() } + .distinct() + val modelIds = listOf(modelKey, model.id.takeIf { it.isNotBlank() }) + .filterNotNull() + .filter { it.isNotBlank() } + .distinct() + providerIds.forEach { providerId -> + modelIds.forEach { modelId -> + indexedModels["$providerId/$modelId"] = model + } + } + + val providerId = model.resolvedProviderId?.takeIf { it.isNotBlank() } ?: provider.id + val modelId = model.id.takeIf { it.isNotBlank() } ?: modelKey + if (providerId.isBlank() || modelId.isBlank()) { + null + } else { + val modelLabel = model.name?.takeIf { it.isNotBlank() } ?: modelId + AppState.ModelOption( + displayName = "$modelLabel ($providerLabel)", + providerId = providerId, + modelId = modelId, + modelLabel = modelLabel + ) to providerLabel + } + } + }.sortedWith( + compareBy> { it.second } + .thenBy { it.first.modelLabel } + .thenBy { it.first.providerId } + .thenBy { it.first.modelId } + ).map { it.first } + return ModelCatalog(options = options, providerModelsIndex = indexedModels) +} + +internal fun legacyPresetIndexToModelInfo( + models: List, + legacyIndex: Int? +): Message.ModelInfo? { + val preset = legacyIndex?.let { ModelPresets.list.getOrNull(it) } ?: return null + val index = modelIndexFor(models, preset.providerId, preset.modelId) ?: return null + val model = models[index] + return Message.ModelInfo(model.providerId, model.modelId) +} + internal fun parseSessionCreatedEvent(event: SSEEvent): SessionCreatedEvent? { val sessionJson = event.payload.getJsonObject("session") ?: return null return runCatching { diff --git a/app/src/main/java/com/yage/opencode_client/ui/ModelPresets.kt b/app/src/main/java/com/yage/opencode_client/ui/ModelPresets.kt index 9c918ac2..42ac4469 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/ModelPresets.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/ModelPresets.kt @@ -1,8 +1,8 @@ package com.yage.opencode_client.ui /** - * Curated model presets for the model selector, matching iOS implementation. - * Only these models are shown in the dropdown instead of the full API list. + * Legacy model presets used only to migrate pre-dynamic-selector saved indexes. + * The chat model selector is populated from the server provider list. */ object ModelPresets { val list: List = listOf( diff --git a/app/src/main/java/com/yage/opencode_client/ui/chat/ChatScreen.kt b/app/src/main/java/com/yage/opencode_client/ui/chat/ChatScreen.kt index d193dcd9..3ad28245 100644 --- a/app/src/main/java/com/yage/opencode_client/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/yage/opencode_client/ui/chat/ChatScreen.kt @@ -60,13 +60,14 @@ fun ChatScreen( val lifecycleOwner = LocalLifecycleOwner.current val scope = rememberCoroutineScope() val aiBuilderToken = sanitizeBearerToken(viewModel.getAIBuilderSettings().token) + val microphonePermissionDeniedMessage = stringResource(R.string.chat_microphone_permission_denied) val audioPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() ) { granted -> if (granted) { viewModel.toggleRecording() } else { - viewModel.setSpeechError(context.getString(R.string.chat_microphone_permission_denied)) + viewModel.setSpeechError(microphonePermissionDeniedMessage) } } val imagePickerLauncher = rememberLauncherForActivityResult( diff --git a/app/src/main/java/com/yage/opencode_client/util/SettingsManager.kt b/app/src/main/java/com/yage/opencode_client/util/SettingsManager.kt index 22287cd8..18a97f19 100644 --- a/app/src/main/java/com/yage/opencode_client/util/SettingsManager.kt +++ b/app/src/main/java/com/yage/opencode_client/util/SettingsManager.kt @@ -6,6 +6,7 @@ import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.serialization.encodeToString +import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton @@ -74,29 +75,28 @@ class SettingsManager @Inject constructor( set(value) = encryptedPrefs.edit().putString(KEY_SESSION_ID, value).apply() var selectedModelIndex: Int - get() = encryptedPrefs.getInt(KEY_MODEL_INDEX, 1) + get() = encryptedPrefs.getInt(KEY_MODEL_INDEX, -1) set(value) = encryptedPrefs.edit().putInt(KEY_MODEL_INDEX, value).apply() - fun migrateRemovedGpt56SolProModelIndices() { - if (encryptedPrefs.getInt(KEY_MODEL_PRESET_SCHEMA_VERSION, 0) >= MODEL_PRESET_SCHEMA_VERSION) return + var selectedModelProviderId: String? + get() = encryptedPrefs.getString(KEY_MODEL_PROVIDER_ID, null) + set(value) = encryptedPrefs.edit().putString(KEY_MODEL_PROVIDER_ID, value).apply() - val sessionModels = encryptedPrefs.getString(KEY_SESSION_MODELS, null)?.let { encoded -> - try { - Json.decodeFromString>(encoded).mapValues { (_, value) -> - value.toIntOrNull()?.let(::migrateLegacyModelIndex)?.toString() ?: value - } - } catch (_: Exception) { - null - } - } + var selectedModelId: String? + get() = encryptedPrefs.getString(KEY_MODEL_ID, null) + set(value) = encryptedPrefs.edit().putString(KEY_MODEL_ID, value).apply() - encryptedPrefs.edit().apply { - if (encryptedPrefs.contains(KEY_MODEL_INDEX)) { - putInt(KEY_MODEL_INDEX, migrateLegacyModelIndex(encryptedPrefs.getInt(KEY_MODEL_INDEX, 1))) - } - if (sessionModels != null) putString(KEY_SESSION_MODELS, Json.encodeToString(sessionModels)) - putInt(KEY_MODEL_PRESET_SCHEMA_VERSION, MODEL_PRESET_SCHEMA_VERSION) - }.apply() + fun selectedModelSelection(): Pair? { + val providerId = selectedModelProviderId?.takeIf { it.isNotBlank() } ?: return null + val modelId = selectedModelId?.takeIf { it.isNotBlank() } ?: return null + return providerId to modelId + } + + fun setSelectedModel(providerId: String, modelId: String) { + encryptedPrefs.edit() + .putString(KEY_MODEL_PROVIDER_ID, providerId) + .putString(KEY_MODEL_ID, modelId) + .apply() } var selectedAgentName: String? @@ -195,6 +195,31 @@ class SettingsManager @Inject constructor( encryptedPrefs.edit().putString(KEY_SESSION_MODELS, Json.encodeToString(map)).apply() } + fun getModelSelectionForSession(sessionId: String): Pair? { + val json = encryptedPrefs.getString(KEY_SESSION_MODEL_SELECTIONS, null) ?: return null + return try { + val selection = Json.decodeFromString>(json)[sessionId] + if (selection != null && selection.providerId.isNotBlank() && selection.modelId.isNotBlank()) { + selection.providerId to selection.modelId + } else { + null + } + } catch (e: Exception) { + null + } + } + + fun setModelForSession(sessionId: String, providerId: String, modelId: String) { + val json = encryptedPrefs.getString(KEY_SESSION_MODEL_SELECTIONS, null) + val map: MutableMap = try { + json?.let { Json.decodeFromString>(it).toMutableMap() } ?: mutableMapOf() + } catch (e: Exception) { + mutableMapOf() + } + map[sessionId] = StoredModelSelection(providerId, modelId) + encryptedPrefs.edit().putString(KEY_SESSION_MODEL_SELECTIONS, Json.encodeToString(map)).apply() + } + fun getAgentForSession(sessionId: String): String? { val json = encryptedPrefs.getString(KEY_SESSION_AGENTS, null) ?: return null return try { @@ -233,7 +258,8 @@ class SettingsManager @Inject constructor( private const val KEY_KNOWN_HOSTS = "ssh_known_hosts_json" private const val KEY_SESSION_ID = "session_id" private const val KEY_MODEL_INDEX = "model_index" - private const val KEY_MODEL_PRESET_SCHEMA_VERSION = "model_preset_schema_version" + private const val KEY_MODEL_PROVIDER_ID = "model_provider_id" + private const val KEY_MODEL_ID = "model_id" private const val KEY_AGENT_NAME = "agent_name" private const val KEY_THEME = "theme" private const val KEY_LANGUAGE = "language" @@ -246,6 +272,7 @@ class SettingsManager @Inject constructor( private const val KEY_AI_USAGE_DASHBOARD_URL = "ai_usage_dashboard_url" private const val KEY_SESSION_DRAFTS = "session_drafts" private const val KEY_SESSION_MODELS = "session_models" + private const val KEY_SESSION_MODEL_SELECTIONS = "session_model_selections" private const val KEY_SESSION_AGENTS = "session_agents" private const val KEY_NFC_ENABLED = "nfc_enabled" private const val KEY_NFC_PROMPT = "nfc_prompt" @@ -255,6 +282,12 @@ class SettingsManager @Inject constructor( private fun basicAuthPasswordKey(passwordId: String): String = "basic_auth_password_$passwordId" } + + @Serializable + private data class StoredModelSelection( + val providerId: String, + val modelId: String + ) } internal fun migrateLegacyModelIndex(index: Int): Int = when (index) { diff --git a/app/src/test/java/com/yage/opencode_client/AppStateTest.kt b/app/src/test/java/com/yage/opencode_client/AppStateTest.kt index 55ad930c..628a8dfd 100644 --- a/app/src/test/java/com/yage/opencode_client/AppStateTest.kt +++ b/app/src/test/java/com/yage/opencode_client/AppStateTest.kt @@ -1,7 +1,7 @@ package com.yage.opencode_client import com.yage.opencode_client.ui.AppState -import com.yage.opencode_client.ui.ModelPresets +import com.yage.opencode_client.ui.buildModelCatalog import com.yage.opencode_client.data.model.* import com.yage.opencode_client.util.ThemeMode import org.junit.Assert.* @@ -27,7 +27,7 @@ class AppStateTest { assertFalse(state.isLoadingMessages) assertTrue(state.agents.isEmpty()) assertEquals("build", state.selectedAgentName) - assertEquals(2, state.selectedModelIndex) + assertEquals(0, state.selectedModelIndex) assertNull(state.providers) assertTrue(state.pendingPermissions.isEmpty()) assertEquals("", state.inputText) @@ -152,34 +152,75 @@ class AppStateTest { return ProvidersResponse(providers = providers) } + private fun stateWithProviders( + providers: ProvidersResponse, + messages: List = emptyList(), + selectedModel: Message.ModelInfo? = null + ): AppState { + val catalog = buildModelCatalog(providers) + return AppState( + messages = messages, + providers = providers, + availableModels = catalog.options, + providerModelsIndex = catalog.providerModelsIndex, + selectedModel = selectedModel + ) + } + @Test - fun `availableModels returns curated presets (filtered like iOS)`() { + fun `availableModels is empty before providers are loaded`() { val state = AppState() val models = state.availableModels - assertEquals(ModelPresets.list.size, models.size) - assertEquals(ModelPresets.list, models) - assertEquals("GLM-5.2", models[0].displayName) - assertEquals("zai-coding-plan", models[0].providerId) - assertEquals("glm-5.2", models[0].modelId) - assertEquals("GPT-5.6 Sol", models[1].displayName) - assertEquals("openai", models[1].providerId) - assertEquals("gpt-5.6-sol", models[1].modelId) - assertFalse(models.any { it.providerId == "openai" && it.modelId == "gpt-5.6-sol-pro" }) - assertTrue(models.any { - it.displayName == "GPT-5.6 Sol Fast" && it.providerId == "openai" && it.modelId == "gpt-5.6-sol-fast" - }) - assertTrue(models.any { - it.displayName == "GPT-5.6 Terra Fast" && it.providerId == "openai" && it.modelId == "gpt-5.6-terra-fast" - }) + assertTrue(models.isEmpty()) } @Test - fun `availableModels independent of providers`() { - val stateWithProviders = AppState(providers = makeProviders(Triple("openai", "gpt-4", "GPT-4"))) - val stateWithoutProviders = AppState(providers = null) - assertEquals(stateWithProviders.availableModels, stateWithoutProviders.availableModels) - assertEquals(ModelPresets.list, stateWithProviders.availableModels) + fun `availableModels returns all provider models sorted by provider and model`() { + val state = stateWithProviders( + makeProviders( + Triple("openai", "gpt-5", "GPT-5"), + Triple("anthropic", "claude-sonnet", "Claude Sonnet"), + Triple("openai", "gpt-4", "GPT-4") + ) + ) + + val models = state.availableModels + + assertEquals( + listOf( + AppState.ModelOption("Claude Sonnet (anthropic)", "anthropic", "claude-sonnet"), + AppState.ModelOption("GPT-4 (openai)", "openai", "gpt-4"), + AppState.ModelOption("GPT-5 (openai)", "openai", "gpt-5") + ), + models + ) + } + + @Test + fun `availableModels falls back to model key and resolved provider id`() { + val providers = ProvidersResponse( + providers = listOf( + ConfigProvider( + id = "proxy", + name = "Proxy", + models = mapOf( + "server-key" to ProviderModel( + id = "", + name = "Server Model", + providerId = "resolved-provider" + ) + ) + ) + ) + ) + + val models = stateWithProviders(providers).availableModels + + assertEquals( + listOf(AppState.ModelOption("Server Model (Proxy)", "resolved-provider", "server-key")), + models + ) } private fun makeContextUsageState( @@ -208,7 +249,7 @@ class AppStateTest { ) ) ) - return AppState(messages = listOf(message), providers = providers) + return stateWithProviders(providers, messages = listOf(message)) } @Test @@ -285,7 +326,7 @@ class AppStateTest { ) ) ) - val state = AppState(messages = listOf(message), providers = providers) + val state = stateWithProviders(providers, messages = listOf(message)) assertNull(state.contextUsage) } @@ -335,7 +376,7 @@ class AppStateTest { ) ) ) - val usage = AppState(messages = listOf(message), providers = providers).contextUsage + val usage = stateWithProviders(providers, messages = listOf(message)).contextUsage assertNotNull(usage) assertEquals(0.5f, usage!!.percentage, 0.001f) @@ -384,10 +425,7 @@ class AppStateTest { ) ) ) - val state = AppState( - messages = listOf(oldAssistant, userMsg, newAssistant), - providers = providers - ) + val state = stateWithProviders(providers, messages = listOf(oldAssistant, userMsg, newAssistant)) val usage = state.contextUsage assertNotNull(usage) @@ -432,9 +470,9 @@ class AppStateTest { ) ) - val usage = AppState( - messages = listOf(usableAssistant, emptyTokenAssistant), - providers = providers + val usage = stateWithProviders( + providers, + messages = listOf(usableAssistant, emptyTokenAssistant) ).contextUsage assertNotNull(usage) diff --git a/app/src/test/java/com/yage/opencode_client/ForkSessionTest.kt b/app/src/test/java/com/yage/opencode_client/ForkSessionTest.kt index 83a01e61..0aeaa4dd 100644 --- a/app/src/test/java/com/yage/opencode_client/ForkSessionTest.kt +++ b/app/src/test/java/com/yage/opencode_client/ForkSessionTest.kt @@ -73,7 +73,7 @@ class ForkSessionTest { every { settingsManager.username } returns null every { settingsManager.password } returns null every { settingsManager.currentSessionId } returns null - every { settingsManager.selectedModelIndex } returns 0 + every { settingsManager.selectedModelIndex } returns -1 every { settingsManager.selectedAgentName } returns null every { settingsManager.themeMode } returns ThemeMode.SYSTEM every { settingsManager.aiBuilderBaseURL } returns "https://space.ai-builders.com/backend" @@ -99,8 +99,12 @@ class ForkSessionTest { every { settingsManager.getDraftText(any()) } returns "" every { settingsManager.setDraftText(any(), any()) } just runs + every { settingsManager.selectedModelSelection() } returns null + every { settingsManager.setSelectedModel(any(), any()) } just runs every { settingsManager.getModelForSession(any()) } returns null every { settingsManager.setModelForSession(any(), any()) } just runs + every { settingsManager.getModelSelectionForSession(any()) } returns null + every { settingsManager.setModelForSession(any(), any(), any()) } just runs every { settingsManager.getAgentForSession(any()) } returns null every { settingsManager.setAgentForSession(any(), any()) } just runs diff --git a/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt b/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt index e4984c5d..ebdd8d35 100644 --- a/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt +++ b/app/src/test/java/com/yage/opencode_client/MainViewModelTest.kt @@ -14,6 +14,9 @@ import com.yage.opencode_client.data.model.SSEPayload import com.yage.opencode_client.data.model.HealthResponse import com.yage.opencode_client.data.model.HostProfile import com.yage.opencode_client.data.model.HostTransport +import com.yage.opencode_client.data.model.ConfigProvider +import com.yage.opencode_client.data.model.ProviderModel +import com.yage.opencode_client.data.model.ProvidersResponse import com.yage.opencode_client.data.repository.HostProfileStore import com.yage.opencode_client.data.repository.OpenCodeRepository import com.yage.opencode_client.ssh.SSHKeyManager @@ -21,7 +24,7 @@ import com.yage.opencode_client.ssh.TunnelManager import com.yage.opencode_client.ui.AppState import com.yage.opencode_client.ui.DeepLinkError import com.yage.opencode_client.ui.MainViewModel -import com.yage.opencode_client.ui.ModelPresets +import com.yage.opencode_client.ui.buildModelCatalog import com.yage.opencode_client.ui.session.buildSessionTree import com.yage.opencode_client.util.SettingsManager import com.yage.opencode_client.util.ThemeMode @@ -37,12 +40,14 @@ import io.mockk.mockkStatic import io.mockk.runs import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runCurrent import kotlinx.serialization.json.JsonPrimitive @@ -95,7 +100,7 @@ class MainViewModelTest { every { settingsManager.username } returns null every { settingsManager.password } returns null every { settingsManager.currentSessionId } returns null - every { settingsManager.selectedModelIndex } returns 0 + every { settingsManager.selectedModelIndex } returns -1 every { settingsManager.selectedAgentName } returns null every { settingsManager.themeMode } returns ThemeMode.SYSTEM every { settingsManager.aiBuilderBaseURL } returns "https://space.ai-builders.com/backend" @@ -121,8 +126,12 @@ class MainViewModelTest { every { settingsManager.getDraftText(any()) } returns "" every { settingsManager.setDraftText(any(), any()) } just runs + every { settingsManager.selectedModelSelection() } returns null + every { settingsManager.setSelectedModel(any(), any()) } just runs every { settingsManager.getModelForSession(any()) } returns null every { settingsManager.setModelForSession(any(), any()) } just runs + every { settingsManager.getModelSelectionForSession(any()) } returns null + every { settingsManager.setModelForSession(any(), any(), any()) } just runs every { settingsManager.getAgentForSession(any()) } returns null every { settingsManager.setAgentForSession(any(), any()) } just runs @@ -131,6 +140,7 @@ class MainViewModelTest { coEvery { repository.getSessionStatus() } returns Result.success(emptyMap()) coEvery { repository.getMessages(any(), any()) } returns Result.success(emptyList()) coEvery { repository.getPendingPermissions() } returns Result.success(emptyList()) + coEvery { repository.getProviders() } returns Result.success(ProvidersResponse()) } private fun createViewModel(): MainViewModel { @@ -145,18 +155,71 @@ class MainViewModelTest { flow.value = transform(flow.value) } + private fun AppState.withProviders(providers: ProvidersResponse): AppState { + val catalog = buildModelCatalog(providers) + return copy( + providers = providers, + availableModels = catalog.options, + providerModelsIndex = catalog.providerModelsIndex + ) + } + private fun handleSse(viewModel: MainViewModel, event: SSEEvent) { val method = MainViewModel::class.java.getDeclaredMethod("handleSSEEvent", SSEEvent::class.java) method.isAccessible = true method.invoke(viewModel, event) } + private fun loadProviders(viewModel: MainViewModel) { + val method = MainViewModel::class.java.getDeclaredMethod("loadProviders") + method.isAccessible = true + method.invoke(viewModel) + } + private fun sha256(input: String): String { return MessageDigest.getInstance("SHA-256") .digest(input.toByteArray()) .joinToString("") { "%02x".format(it) } } + private fun providersResponse( + defaultProviderId: String? = null, + defaultModelId: String? = null + ): ProvidersResponse { + return ProvidersResponse( + providers = listOf( + ConfigProvider( + id = "anthropic", + models = mapOf("claude-sonnet" to ProviderModel(id = "claude-sonnet", name = "Claude Sonnet")) + ), + ConfigProvider( + id = "openai", + models = mapOf("gpt-5" to ProviderModel(id = "gpt-5", name = "GPT-5")) + ) + ), + defaultByProvider = if (defaultProviderId != null && defaultModelId != null) { + mapOf(defaultProviderId to defaultModelId) + } else { + emptyMap() + } + ) + } + + private fun providersWithLegacyPreset(): ProvidersResponse { + return ProvidersResponse( + providers = listOf( + ConfigProvider( + id = "anthropic", + models = mapOf("claude-sonnet" to ProviderModel(id = "claude-sonnet", name = "Claude Sonnet")) + ), + ConfigProvider( + id = "openai", + models = mapOf("gpt-5.5" to ProviderModel(id = "gpt-5.5", name = "GPT-5.5")) + ) + ) + ) + } + @Test fun `deep link stays pending until connected`() = runTest { val viewModel = createViewModel() @@ -328,13 +391,14 @@ class MainViewModelTest { } @Test - fun `init clamps saved model index and configures repository`() = runTest { + fun `init leaves model selection unresolved until providers load and configures repository`() = runTest { every { settingsManager.selectedModelIndex } returns 999 + every { settingsManager.selectedModelSelection() } returns ("openai" to "gpt-5") val viewModel = createViewModel() - assertEquals(ModelPresets.list.lastIndex, viewModel.state.value.selectedModelIndex) - verify { settingsManager.selectedModelIndex = ModelPresets.list.lastIndex } + assertNull(viewModel.state.value.selectedModel) + verify(exactly = 0) { settingsManager.selectedModelIndex = any() } verify { repository.configure("http://server.test", null, null) } } @@ -352,13 +416,14 @@ class MainViewModelTest { } @Test - fun `sendMessage success clears input and uses selected preset model`() = runTest { + fun `sendMessage success clears input and uses selected server model`() = runTest { coEvery { repository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) coEvery { repository.getSessions(100) } returns Result.success( listOf(com.yage.opencode_client.data.model.Session(id = "session-1", directory = "/tmp/project")) ) val viewModel = createViewModel() + updateState(viewModel) { it.withProviders(providersResponse()) } viewModel.selectSession("session-1") advanceUntilIdle() viewModel.setInputText(" hello world ") @@ -368,13 +433,12 @@ class MainViewModelTest { viewModel.sendMessage() advanceUntilIdle() - val selected = ModelPresets.list[1] coVerify { repository.sendMessage( "session-1", "hello world", "review", - Message.ModelInfo(selected.providerId, selected.modelId) + Message.ModelInfo("openai", "gpt-5") ) } assertEquals("", viewModel.state.value.inputText) @@ -931,8 +995,7 @@ class MainViewModelTest { } @Test - fun `loadMessages updates selected agent and preset model from last assistant`() = runTest { - val preset = ModelPresets.list[2] + fun `loadMessages updates selected agent and server model from last assistant`() = runTest { val messages = listOf( MessageWithParts(info = Message(id = "u1", role = "user")), MessageWithParts( @@ -940,21 +1003,79 @@ class MainViewModelTest { id = "a1", role = "assistant", agent = "plan", - model = Message.ModelInfo(preset.providerId, preset.modelId) + model = Message.ModelInfo("openai", "gpt-5") ) ) ) coEvery { repository.getMessages("session-1", 30) } returns Result.success(messages) val viewModel = createViewModel() - updateState(viewModel) { it.copy(currentSessionId = "session-1") } + updateState(viewModel) { it.withProviders(providersResponse()).copy(currentSessionId = "session-1") } viewModel.loadMessages("session-1") advanceUntilIdle() assertEquals(messages, viewModel.state.value.messages) assertEquals("plan", viewModel.state.value.selectedAgentName) - assertEquals(2, viewModel.state.value.selectedModelIndex) + assertEquals(Message.ModelInfo("openai", "gpt-5"), viewModel.state.value.selectedModel) + } + + @Test + fun `loadProviders loads provider models and selects server default`() = runTest { + coEvery { repository.getProviders() } returns Result.success(providersResponse("openai", "gpt-5")) + + val viewModel = createViewModel() + + loadProviders(viewModel) + advanceUntilIdle() + + assertEquals( + listOf( + AppState.ModelOption("Claude Sonnet (anthropic)", "anthropic", "claude-sonnet"), + AppState.ModelOption("GPT-5 (openai)", "openai", "gpt-5") + ), + viewModel.state.value.availableModels + ) + assertEquals(Message.ModelInfo("openai", "gpt-5"), viewModel.state.value.selectedModel) + } + + @Test + fun `loadProviders selects first model when no saved default or legacy index exists`() = runTest { + every { settingsManager.selectedModelIndex } returns -1 + coEvery { repository.getProviders() } returns Result.success(providersResponse()) + + val viewModel = createViewModel() + + loadProviders(viewModel) + advanceUntilIdle() + + assertEquals(Message.ModelInfo("anthropic", "claude-sonnet"), viewModel.state.value.selectedModel) + } + + @Test + fun `loadProviders infers model from already loaded messages when providers arrive later`() = runTest { + val messages = listOf( + MessageWithParts( + info = Message( + id = "a1", + role = "assistant", + model = Message.ModelInfo("openai", "gpt-5") + ) + ) + ) + coEvery { repository.getMessages("session-1", 30) } returns Result.success(messages) + coEvery { repository.getProviders() } returns Result.success(providersResponse()) + + val viewModel = createViewModel() + updateState(viewModel) { it.copy(currentSessionId = "session-1") } + viewModel.loadMessages("session-1") + advanceUntilIdle() + assertNull(viewModel.state.value.selectedModel) + + loadProviders(viewModel) + advanceUntilIdle() + + assertEquals(Message.ModelInfo("openai", "gpt-5"), viewModel.state.value.selectedModel) } @Test @@ -1275,13 +1396,72 @@ class MainViewModelTest { } @Test - fun `selectModel with active session saves model index per session`() = runTest { + fun `selectModel with active session saves stable model selection per session`() = runTest { val viewModel = createViewModel() - updateState(viewModel) { it.copy(currentSessionId = "s1") } + updateState(viewModel) { it.withProviders(providersResponse()).copy(currentSessionId = "s1") } - viewModel.selectModel(2) + viewModel.selectModel(1) - verify { settingsManager.setModelForSession("s1", 2) } + verify { settingsManager.setSelectedModel("openai", "gpt-5") } + verify { settingsManager.setModelForSession("s1", "openai", "gpt-5") } + verify(exactly = 0) { settingsManager.selectedModelIndex = any() } + verify(exactly = 0) { settingsManager.setModelForSession("s1", any()) } + assertEquals(Message.ModelInfo("openai", "gpt-5"), viewModel.state.value.selectedModel) + } + + @Test + fun `testConnection clears stale provider models before probing current profile`() = runTest { + coEvery { repository.checkHealth() } returns Result.success(HealthResponse(healthy = false, version = "1.0")) + every { settingsManager.selectedModelSelection() } returns ("openai" to "gpt-5") + + val viewModel = createViewModel() + updateState(viewModel) { + it.withProviders(providersResponse()).copy(selectedModel = Message.ModelInfo("anthropic", "claude-sonnet")) + } + + viewModel.testConnection(force = true) + advanceUntilIdle() + + assertNull(viewModel.state.value.providers) + assertTrue(viewModel.state.value.availableModels.isEmpty()) + assertNull(viewModel.state.value.selectedModel) + } + + @Test + fun `testConnection keeps model catalog when profile is unchanged`() = runTest { + coEvery { repository.checkHealth() } returns Result.success(HealthResponse(healthy = false, version = "1.0")) + + val viewModel = createViewModel() + viewModel.testConnection(force = true) + advanceUntilIdle() + + updateState(viewModel) { + it.withProviders(providersResponse()).copy(selectedModel = Message.ModelInfo("openai", "gpt-5")) + } + + viewModel.testConnection(force = true) + advanceUntilIdle() + + assertTrue(viewModel.state.value.availableModels.isNotEmpty()) + assertEquals(Message.ModelInfo("openai", "gpt-5"), viewModel.state.value.selectedModel) + } + + @Test + fun `provider response from previous server is ignored after reconfigure`() = runTest { + val oldServerResponse = CompletableDeferred>() + coEvery { repository.getProviders() } coAnswers { oldServerResponse.await() } + + val viewModel = createViewModel() + loadProviders(viewModel) + runCurrent() + + viewModel.configureServer("http://new-server.test") + oldServerResponse.complete(Result.success(providersResponse("openai", "gpt-5"))) + advanceUntilIdle() + + assertNull(viewModel.state.value.providers) + assertTrue(viewModel.state.value.availableModels.isEmpty()) + assertNull(viewModel.state.value.selectedModel) } @Test @@ -1310,27 +1490,91 @@ class MainViewModelTest { } @Test - fun `loadMessages uses per-session saved model index over message inference`() = runTest { - val inferredPreset = ModelPresets.list[2] + fun `sendMessage omits persisted model while providers are not loaded`() = runTest { + every { settingsManager.selectedModelSelection() } returns ("openai" to "gpt-5") + coEvery { repository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) + + val viewModel = createViewModel() + viewModel.selectSession("s1") + advanceUntilIdle() + viewModel.setInputText("hello") + + viewModel.sendMessage() + advanceUntilIdle() + + coVerify { + repository.sendMessage( + "s1", + "hello", + "build", + null + ) + } + } + + @Test + fun `selectSession falls back to global model instead of previous session model`() = runTest { + every { settingsManager.selectedModelSelection() } returns ("openai" to "gpt-5") + + val viewModel = createViewModel() + updateState(viewModel) { + it.withProviders(providersResponse()).copy( + currentSessionId = "session-a", + selectedModel = Message.ModelInfo("anthropic", "claude-sonnet") + ) + } + + viewModel.selectSession("session-b") + + assertEquals(Message.ModelInfo("openai", "gpt-5"), viewModel.state.value.selectedModel) + advanceUntilIdle() + assertEquals(Message.ModelInfo("openai", "gpt-5"), viewModel.state.value.selectedModel) + } + + @Test + fun `loadMessages uses per-session saved model selection over message inference`() = runTest { val messages = listOf( MessageWithParts( info = Message( id = "a1", role = "assistant", - model = Message.ModelInfo(inferredPreset.providerId, inferredPreset.modelId) + model = Message.ModelInfo("anthropic", "claude-sonnet") ) ) ) coEvery { repository.getMessages("session-1", 30) } returns Result.success(messages) - every { settingsManager.getModelForSession("session-1") } returns 3 + every { settingsManager.getModelSelectionForSession("session-1") } returns ("openai" to "gpt-5") val viewModel = createViewModel() - updateState(viewModel) { it.copy(currentSessionId = "session-1") } + updateState(viewModel) { it.withProviders(providersResponse()).copy(currentSessionId = "session-1") } + + viewModel.loadMessages("session-1") + advanceUntilIdle() + + assertEquals(Message.ModelInfo("openai", "gpt-5"), viewModel.state.value.selectedModel) + } + + @Test + fun `loadMessages uses message inference before legacy session model selection`() = runTest { + val messages = listOf( + MessageWithParts( + info = Message( + id = "a1", + role = "assistant", + model = Message.ModelInfo("anthropic", "claude-sonnet") + ) + ) + ) + coEvery { repository.getMessages("session-1", 30) } returns Result.success(messages) + every { settingsManager.getModelForSession("session-1") } returns 1 + + val viewModel = createViewModel() + updateState(viewModel) { it.withProviders(providersWithLegacyPreset()).copy(currentSessionId = "session-1") } viewModel.loadMessages("session-1") advanceUntilIdle() - assertEquals(3, viewModel.state.value.selectedModelIndex) + assertEquals(Message.ModelInfo("anthropic", "claude-sonnet"), viewModel.state.value.selectedModel) } @Test diff --git a/app/src/test/java/com/yage/opencode_client/ModelTests.kt b/app/src/test/java/com/yage/opencode_client/ModelTests.kt index ab7e269f..295e0582 100644 --- a/app/src/test/java/com/yage/opencode_client/ModelTests.kt +++ b/app/src/test/java/com/yage/opencode_client/ModelTests.kt @@ -324,8 +324,8 @@ class ModelTests { } @Test - fun `ModelOption shortName returns GPT for GPT models`() { - assertEquals("GPT", modelOption("GPT-4o").shortName) + fun `ModelOption shortName keeps version word for GPT models`() { + assertEquals("GPT-4o", modelOption("GPT-4o").shortName) } @Test @@ -351,13 +351,19 @@ class ModelTests { } @Test - fun `ModelOption shortName returns OGLM-5_2 for Ollama GLM 5_2`() { - assertEquals("OGLM-5.2", modelOption("Ollama GLM 5.2").shortName) + fun `ModelOption shortName returns GLM for GLM models`() { + assertEquals("GLM", modelOption("Ollama GLM 5.2").shortName) } @Test fun `ModelOption shortName returns first word for unknown models`() { - assertEquals("DeepSeek", modelOption("DeepSeek V3").shortName) + assertEquals("Mistral", modelOption("Mistral Large").shortName) + } + + @Test + fun `ModelOption shortName ignores provider suffix for dynamic labels`() { + assertEquals("Sonnet", modelOption("Claude Sonnet (anthropic)").shortName) + assertEquals("GPT-5", modelOption("GPT-5 (openai)").shortName) } @Test diff --git a/app/src/test/java/com/yage/opencode_client/NfcQuickPromptTest.kt b/app/src/test/java/com/yage/opencode_client/NfcQuickPromptTest.kt index bcc24ebf..00b20039 100644 --- a/app/src/test/java/com/yage/opencode_client/NfcQuickPromptTest.kt +++ b/app/src/test/java/com/yage/opencode_client/NfcQuickPromptTest.kt @@ -71,7 +71,7 @@ class NfcQuickPromptTest { every { settingsManager.username } returns null every { settingsManager.password } returns null every { settingsManager.currentSessionId } returns null - every { settingsManager.selectedModelIndex } returns 0 + every { settingsManager.selectedModelIndex } returns -1 every { settingsManager.selectedAgentName } returns null every { settingsManager.themeMode } returns ThemeMode.SYSTEM every { settingsManager.aiBuilderBaseURL } returns "https://space.ai-builders.com/backend" @@ -97,8 +97,12 @@ class NfcQuickPromptTest { every { settingsManager.getDraftText(any()) } returns "" every { settingsManager.setDraftText(any(), any()) } just runs + every { settingsManager.selectedModelSelection() } returns null + every { settingsManager.setSelectedModel(any(), any()) } just runs every { settingsManager.getModelForSession(any()) } returns null every { settingsManager.setModelForSession(any(), any()) } just runs + every { settingsManager.getModelSelectionForSession(any()) } returns null + every { settingsManager.setModelForSession(any(), any(), any()) } just runs every { settingsManager.getAgentForSession(any()) } returns null every { settingsManager.setAgentForSession(any(), any()) } just runs @@ -209,4 +213,4 @@ class NfcQuickPromptTest { assertEquals("hello", action.prompt) assertEquals(true, action.autoSend) } -} \ No newline at end of file +} diff --git a/docs/dynamic-opencode-model-selector-plan.md b/docs/dynamic-opencode-model-selector-plan.md new file mode 100644 index 00000000..c6d5e3e1 --- /dev/null +++ b/docs/dynamic-opencode-model-selector-plan.md @@ -0,0 +1,58 @@ +# Dynamic OpenCode Model Selector + +## Summary + +Replace the chat tab's hardcoded `ModelPresets.list` selector with the full model list returned by the configured OpenCode server's existing `GET /config/providers` API. Selection will persist by `providerId/modelId`, and sending a prompt will continue to pass the selected model through the existing `PromptRequest.model` payload. + +## Key Changes + +- Convert `ProvidersResponse` into `AppState.ModelOption` entries from all server providers/models: + - `providerId = model.providerID/providerId if present, else provider.id` + - `modelId = model.id if present, else map key` + - display the model name plus provider identity for disambiguation + - sort deterministically by provider then model +- Change `AppState.availableModels` to derive from `providers` instead of `ModelPresets.list`; before providers load or after load failure, the selector shows the existing "No models" empty state. +- Update selection resolution: + - prefer per-session saved `providerId/modelId` + - then infer from the latest assistant message + - then global saved `providerId/modelId` + - then server `default` + - then legacy saved index mapped through `ModelPresets.list` as migration fallback + - then first server model +- Extend `SettingsManager` with stable model persistence: + - global selected model provider/id + - per-session selected model provider/id + - keep old index-based methods only for backward-compatible migration. +- On host/server profile change, clear stale providers until the new server's provider list loads. +- Keep `buildSelectedModel()` behavior: use selected dynamic model when valid, otherwise server default, otherwise omit model from prompt. + +## Implementation Targets + +- `app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt`: dynamic `availableModels`, selection defaults, host/profile reset behavior. +- `app/src/main/java/com/yage/opencode_client/ui/MainViewModelSessionActions.kt`: provider loading, message inference, send model resolution. +- `app/src/main/java/com/yage/opencode_client/util/SettingsManager.kt`: stable model persistence and legacy migration helpers. +- `app/src/main/java/com/yage/opencode_client/ui/chat/ChatTopBar.kt`: render dynamic menu labels without changing the top-level interaction. + +## Test Plan + +- Update AppState/model tests so `availableModels` is empty without providers and equals all server models when providers are present. +- Add tests for provider/model mapping, duplicate disambiguation, model id fallback, resolved provider id, and server default selection. +- Update ViewModel tests for: + - `loadProviders()` populates dynamic options and selects server default + - `selectModel()` persists provider/model globally and per session + - `loadMessages()` restores per-session model or infers latest assistant model + - `sendMessage()` sends the selected dynamic provider/model + - legacy index migration still maps old saved presets when matching server models exist. +- Run: + ```bash + export JAVA_HOME="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/Applications/Android Studio.app/Contents/jbr/Contents/Home" + export PATH="$JAVA_HOME/bin:$PATH" + ./gradlew testDebugUnitTest + ``` +- Do not run `connectedDebugAndroidTest` unless explicitly requested, and only target an emulator. + +## Assumptions + +- The desired behavior is the full OpenCode server model list, not a curated client list. +- `GET /config/providers` is the source of truth; no new server endpoint is needed. +- Existing prompt send API shape remains unchanged: `providerID` and `modelID`.