Merge pull request #1203 from Hy4ri/dev
chore(release): merge dev to main
This commit is contained in:
10 files changed
+837
-45
No files matched your search
+19
@@ -26,6 +26,25 @@ class ModelPickerDialogTest {
|
|||||||
@get:Rule
|
@get:Rule
|
||||||
val composeTestRule = createComposeRule()
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pinnedModels_areSelectableWhileCatalogLoads() {
|
||||||
|
var selectedModel = ""
|
||||||
|
composeTestRule.setContent {
|
||||||
|
HermesControlTheme {
|
||||||
|
ModelPickerDialog(
|
||||||
|
providers = emptyList(),
|
||||||
|
title = "Choose model",
|
||||||
|
isLoading = true,
|
||||||
|
pinnedModels = listOf(PinnedModel("openai", "gpt-4")),
|
||||||
|
onSelect = { _, model -> selectedModel = model },
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText("gpt-4").assertIsDisplayed().performClick()
|
||||||
|
assertEquals("gpt-4", selectedModel)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun providers_startCollapsed_andOnlyOneProviderExpands() {
|
fun providers_startCollapsed_andOnlyOneProviderExpands() {
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.local.DataScope
|
||||||
|
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkError
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
data class ModelCatalogState(
|
||||||
|
val scope: DataScope? = null,
|
||||||
|
val providers: List<ModelProvider> = emptyList(),
|
||||||
|
val hasLoaded: Boolean = false,
|
||||||
|
val isRefreshing: Boolean = false,
|
||||||
|
val error: NetworkError? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared in-memory catalog cache partitioned by [DataScope].
|
||||||
|
* Deduplicates in-flight catalog loads and shares parsed [ModelProvider]s
|
||||||
|
* across Chat and Model screens.
|
||||||
|
*/
|
||||||
|
class ModelCatalogStore(
|
||||||
|
private val repository: ModelOptionsRepository = ModelOptionsRepository(),
|
||||||
|
private val getCurrentScope: () -> DataScope? = { AuthManager.currentDataScope() },
|
||||||
|
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
||||||
|
private val ttlMs: Long = 60_000L,
|
||||||
|
) {
|
||||||
|
private val _state = MutableStateFlow(ModelCatalogState())
|
||||||
|
val state: StateFlow<ModelCatalogState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var lastLoadedTime: Long = 0L
|
||||||
|
private var activeResult: CompletableDeferred<NetworkResult<ModelOptionsResponse>>? = null
|
||||||
|
private val inFlightFetches = mutableListOf<Job>()
|
||||||
|
private var activeIsForced: Boolean = false
|
||||||
|
private var activeGeneration: Long = 0L
|
||||||
|
|
||||||
|
fun attachScopeObserver(
|
||||||
|
externalScope: CoroutineScope,
|
||||||
|
dataScopeFlow: Flow<DataScope?>,
|
||||||
|
) {
|
||||||
|
externalScope.launch {
|
||||||
|
dataScopeFlow.collect { newScope ->
|
||||||
|
onScopeChanged(newScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun onScopeChanged(newScope: DataScope?) {
|
||||||
|
mutex.withLock {
|
||||||
|
if (_state.value.scope != newScope) {
|
||||||
|
resetForScopeLocked(newScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates all work belonging to the previous data scope.
|
||||||
|
*
|
||||||
|
* Unlike force-refresh supersession, a scope switch really does make the
|
||||||
|
* old network work invalid, so those fetches are cancelled.
|
||||||
|
*/
|
||||||
|
private fun resetForScopeLocked(newScope: DataScope?) {
|
||||||
|
inFlightFetches.forEach { it.cancel() }
|
||||||
|
inFlightFetches.clear()
|
||||||
|
|
||||||
|
activeResult?.cancel()
|
||||||
|
activeResult = null
|
||||||
|
activeIsForced = false
|
||||||
|
activeGeneration++
|
||||||
|
lastLoadedTime = 0L
|
||||||
|
_state.value = ModelCatalogState(scope = newScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts one physical catalog request for the current logical load.
|
||||||
|
*
|
||||||
|
* Multiple callers await [resultGate]. A forced refresh may supersede a
|
||||||
|
* normal physical request without cancelling that gate, so callers already
|
||||||
|
* waiting on the normal request transparently receive the forced result.
|
||||||
|
*/
|
||||||
|
private fun startFetchLocked(
|
||||||
|
refresh: Boolean,
|
||||||
|
requestScope: DataScope?,
|
||||||
|
generation: Long,
|
||||||
|
resultGate: CompletableDeferred<NetworkResult<ModelOptionsResponse>>,
|
||||||
|
) {
|
||||||
|
inFlightFetches.removeAll { it.isCompleted }
|
||||||
|
inFlightFetches +=
|
||||||
|
scope.launch {
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
repository.load(refresh = refresh)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
NetworkResult.Failure(
|
||||||
|
NetworkError.Unknown(t.message ?: "Unknown error", t),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutex.withLock {
|
||||||
|
// A newer forced request or scope switch owns publication.
|
||||||
|
if (
|
||||||
|
generation != activeGeneration ||
|
||||||
|
_state.value.scope != requestScope ||
|
||||||
|
activeResult !== resultGate
|
||||||
|
) {
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
|
||||||
|
activeResult = null
|
||||||
|
activeIsForced = false
|
||||||
|
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
lastLoadedTime = clock()
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
providers = result.data.providers.orEmpty(),
|
||||||
|
hasLoaded = true,
|
||||||
|
isRefreshing = false,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isRefreshing = false,
|
||||||
|
error = result.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete only from the authoritative generation.
|
||||||
|
// Every consumer of this logical load receives the same result.
|
||||||
|
resultGate.complete(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun ensureLoaded(forceRefresh: Boolean = false): NetworkResult<ModelOptionsResponse> {
|
||||||
|
val currentScope = getCurrentScope()
|
||||||
|
|
||||||
|
val resultGate =
|
||||||
|
mutex.withLock {
|
||||||
|
// Scope switch check
|
||||||
|
if (_state.value.scope != currentScope) {
|
||||||
|
resetForScopeLocked(currentScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
val now = clock()
|
||||||
|
val isFresh = _state.value.hasLoaded && (now - lastLoadedTime < ttlMs)
|
||||||
|
|
||||||
|
if (!forceRefresh && isFresh) {
|
||||||
|
return NetworkResult.Success(ModelOptionsResponse(_state.value.providers))
|
||||||
|
}
|
||||||
|
|
||||||
|
val existing = activeResult
|
||||||
|
if (existing?.isActive == true) {
|
||||||
|
if (forceRefresh && !activeIsForced) {
|
||||||
|
// Supersede the physical request, but deliberately keep
|
||||||
|
// the shared result gate alive. Existing consumers must
|
||||||
|
// not be cancelled just because another screen refreshed.
|
||||||
|
val generation = ++activeGeneration
|
||||||
|
activeIsForced = true
|
||||||
|
_state.update { it.copy(isRefreshing = true) }
|
||||||
|
startFetchLocked(
|
||||||
|
refresh = true,
|
||||||
|
requestScope = currentScope,
|
||||||
|
generation = generation,
|
||||||
|
resultGate = existing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
existing
|
||||||
|
} else {
|
||||||
|
val generation = ++activeGeneration
|
||||||
|
val newResult = CompletableDeferred<NetworkResult<ModelOptionsResponse>>()
|
||||||
|
|
||||||
|
activeResult = newResult
|
||||||
|
activeIsForced = forceRefresh
|
||||||
|
_state.update { it.copy(isRefreshing = true) }
|
||||||
|
|
||||||
|
startFetchLocked(
|
||||||
|
refresh = forceRefresh,
|
||||||
|
requestScope = currentScope,
|
||||||
|
generation = generation,
|
||||||
|
resultGate = newResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
newResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultGate.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val shared by lazy {
|
||||||
|
val store = ModelCatalogStore()
|
||||||
|
store.attachScopeObserver(
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
||||||
|
AuthManager.dataScopeFlow,
|
||||||
|
)
|
||||||
|
store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.local.DataScope
|
||||||
import com.m57.hermescontrol.data.model.ModelCapabilities
|
import com.m57.hermescontrol.data.model.ModelCapabilities
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
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.ApiClient
|
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.WsMethods
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,10 +51,11 @@ class ChatModelSwitchDelegate(
|
|||||||
private val handleSlashCommand: (command: String) -> Unit,
|
private val handleSlashCommand: (command: String) -> Unit,
|
||||||
private val fetchContextUsage: () -> Unit,
|
private val fetchContextUsage: () -> Unit,
|
||||||
private val onModelSwitchInitiated: () -> Unit = {},
|
private val onModelSwitchInitiated: () -> Unit = {},
|
||||||
|
private val dataScopeFlow: StateFlow<DataScope?> = AuthManager.dataScopeFlow,
|
||||||
private val getModelOptionsCall: suspend (
|
private val getModelOptionsCall: suspend (
|
||||||
refresh: Boolean,
|
refresh: Boolean,
|
||||||
) -> NetworkResult<com.m57.hermescontrol.data.model.ModelOptionsResponse> =
|
) -> NetworkResult<com.m57.hermescontrol.data.model.ModelOptionsResponse> =
|
||||||
{ refresh -> safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = 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) },
|
||||||
) {
|
) {
|
||||||
@@ -62,6 +68,43 @@ class ChatModelSwitchDelegate(
|
|||||||
private var unconfirmedTargetModel: String? = null
|
private var unconfirmedTargetModel: String? = null
|
||||||
private var modelSwitchSequence: Long = 0L
|
private var modelSwitchSequence: Long = 0L
|
||||||
private var cachedModelOptions: List<ModelProvider> = emptyList()
|
private var cachedModelOptions: List<ModelProvider> = emptyList()
|
||||||
|
private var cachedModelOptionsScope: DataScope? = dataScopeFlow.value
|
||||||
|
private var modelOptionsJob: Job? = null
|
||||||
|
private var modelOptionsJobScope: DataScope? = null
|
||||||
|
|
||||||
|
fun attachScopeObserver(externalScope: CoroutineScope) {
|
||||||
|
externalScope.launch {
|
||||||
|
dataScopeFlow.collect { newScope ->
|
||||||
|
if (cachedModelOptionsScope == newScope) return@collect
|
||||||
|
|
||||||
|
// A model catalog belongs to exactly one DataScope. Never let
|
||||||
|
// models or capability flags bleed across profile/server/auth
|
||||||
|
// boundaries while the new scope is reconnecting.
|
||||||
|
cachedModelOptions = emptyList()
|
||||||
|
cachedModelOptionsScope = newScope
|
||||||
|
fastRejectedModels.clear()
|
||||||
|
|
||||||
|
if (modelOptionsJobScope != newScope) {
|
||||||
|
modelOptionsJob?.cancel()
|
||||||
|
modelOptionsJob = null
|
||||||
|
modelOptionsJobScope = null
|
||||||
|
}
|
||||||
|
|
||||||
|
val shouldReload = newScope != null && uiState.value.showModelPicker
|
||||||
|
uiState.update {
|
||||||
|
it.copy(
|
||||||
|
modelPickerProviders = emptyList(),
|
||||||
|
modelPickerLoading = shouldReload,
|
||||||
|
currentModelCapabilities = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldReload) {
|
||||||
|
loadModelOptions(refresh = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun isModelPickerCommand(command: String): Boolean {
|
fun isModelPickerCommand(command: String): Boolean {
|
||||||
val trimmed = command.trim()
|
val trimmed = command.trim()
|
||||||
@@ -75,18 +118,13 @@ class ChatModelSwitchDelegate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun preloadModelOptions() {
|
fun preloadModelOptions() {
|
||||||
scope.launch(ioDispatcher) {
|
loadModelOptions(refresh = false)
|
||||||
val result = getModelOptionsCall(false)
|
|
||||||
if (result is NetworkResult.Success) {
|
|
||||||
cachedModelOptions = result.data.providers.orEmpty()
|
|
||||||
uiState.update { it.copy(modelPickerPinned = getPinnedModels()) }
|
|
||||||
syncCurrentModelCapabilities()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openModelPicker() {
|
fun openModelPicker() {
|
||||||
val hasCached = cachedModelOptions.isNotEmpty()
|
val currentScope = dataScopeFlow.value
|
||||||
|
val hasCached =
|
||||||
|
cachedModelOptionsScope == currentScope && cachedModelOptions.isNotEmpty()
|
||||||
uiState.update {
|
uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
showModelPicker = true,
|
showModelPicker = true,
|
||||||
@@ -96,20 +134,56 @@ class ChatModelSwitchDelegate(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!hasCached) {
|
if (!hasCached) {
|
||||||
refreshModelOptions()
|
loadModelOptions(refresh = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshModelOptions() {
|
fun refreshModelOptions() {
|
||||||
|
loadModelOptions(refresh = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadModelOptions(refresh: Boolean) {
|
||||||
|
val requestScope = dataScopeFlow.value
|
||||||
|
|
||||||
|
if (cachedModelOptionsScope != requestScope) {
|
||||||
|
cachedModelOptions = emptyList()
|
||||||
|
cachedModelOptionsScope = requestScope
|
||||||
|
fastRejectedModels.clear()
|
||||||
|
uiState.update {
|
||||||
|
it.copy(
|
||||||
|
modelPickerProviders = emptyList(),
|
||||||
|
currentModelCapabilities = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse only work belonging to this exact DataScope. A reconnect into
|
||||||
|
// another profile/server must not inherit the previous preload.
|
||||||
|
if (modelOptionsJob?.isActive == true) {
|
||||||
|
if (modelOptionsJobScope == requestScope && !refresh) return
|
||||||
|
modelOptionsJob?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
uiState.update { it.copy(modelPickerLoading = true) }
|
uiState.update { it.copy(modelPickerLoading = true) }
|
||||||
scope.launch(ioDispatcher) {
|
modelOptionsJobScope = requestScope
|
||||||
val result = getModelOptionsCall(true)
|
modelOptionsJob =
|
||||||
|
scope.launch {
|
||||||
|
val result = withContext(ioDispatcher) { getModelOptionsCall(refresh) }
|
||||||
|
|
||||||
|
// The scope may have changed while IO was in flight. Never
|
||||||
|
// publish that old scope's result into the current picker.
|
||||||
|
if (dataScopeFlow.value != requestScope) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
cachedModelOptions = result.data.providers.orEmpty()
|
cachedModelOptions = result.data.providers.orEmpty()
|
||||||
|
cachedModelOptionsScope = requestScope
|
||||||
uiState.update {
|
uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
modelPickerProviders = cachedModelOptions,
|
modelPickerProviders = cachedModelOptions,
|
||||||
|
modelPickerPinned = getPinnedModels(),
|
||||||
modelPickerLoading = false,
|
modelPickerLoading = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -120,7 +194,12 @@ class ChatModelSwitchDelegate(
|
|||||||
uiState.update {
|
uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
modelPickerLoading = false,
|
modelPickerLoading = false,
|
||||||
errorMessage = "Failed to load models: ${result.error.message}",
|
errorMessage =
|
||||||
|
if (it.showModelPicker || refresh) {
|
||||||
|
"Failed to load models: ${result.error.message}"
|
||||||
|
} else {
|
||||||
|
it.errorMessage
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,7 +481,13 @@ class ChatModelSwitchDelegate(
|
|||||||
fun getModelCapabilities(
|
fun getModelCapabilities(
|
||||||
providerSlug: String,
|
providerSlug: String,
|
||||||
modelName: String,
|
modelName: String,
|
||||||
): ModelCapabilities? = cachedModelOptions.find { it.slug == providerSlug }?.capabilities?.get(modelName)
|
): ModelCapabilities? {
|
||||||
|
if (cachedModelOptionsScope != dataScopeFlow.value) return null
|
||||||
|
return cachedModelOptions
|
||||||
|
.find { it.slug == providerSlug }
|
||||||
|
?.capabilities
|
||||||
|
?.get(modelName)
|
||||||
|
}
|
||||||
|
|
||||||
fun getCurrentModelCapabilities(): ModelCapabilities? {
|
fun getCurrentModelCapabilities(): ModelCapabilities? {
|
||||||
val label = uiState.value.currentSessionModel ?: return null
|
val label = uiState.value.currentSessionModel ?: return null
|
||||||
@@ -425,7 +510,7 @@ class ChatModelSwitchDelegate(
|
|||||||
} else {
|
} else {
|
||||||
val provider = label.substring(0, idx)
|
val provider = label.substring(0, idx)
|
||||||
val model = label.substring(idx + 1)
|
val model = label.substring(idx + 1)
|
||||||
val baseCaps = cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model)
|
val baseCaps = getModelCapabilities(provider, model)
|
||||||
if (fastRejectedModels.contains(label) && baseCaps?.fast == true) {
|
if (fastRejectedModels.contains(label) && baseCaps?.fast == true) {
|
||||||
baseCaps.copy(fast = false)
|
baseCaps.copy(fast = false)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -479,7 +479,9 @@ class ChatViewModel(
|
|||||||
handleSlashCommand = { cmd -> handleSlashCommand(cmd) },
|
handleSlashCommand = { cmd -> handleSlashCommand(cmd) },
|
||||||
fetchContextUsage = { fetchContextUsage() },
|
fetchContextUsage = { fetchContextUsage() },
|
||||||
onModelSwitchInitiated = { onModelSwitchInitiated() },
|
onModelSwitchInitiated = { onModelSwitchInitiated() },
|
||||||
)
|
).apply {
|
||||||
|
attachScopeObserver(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
private val credentialPromptsDelegate =
|
private val credentialPromptsDelegate =
|
||||||
ChatCredentialPromptsDelegate(
|
ChatCredentialPromptsDelegate(
|
||||||
|
|||||||
@@ -104,11 +104,13 @@ fun ModelScreen(
|
|||||||
onRefresh = { viewModel.loadAll(refresh = true) },
|
onRefresh = { viewModel.loadAll(refresh = true) },
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
when {
|
when {
|
||||||
state.isLoading && state.mainModelProvider.isEmpty() && state.providers.isEmpty() -> {
|
state.isLoading && state.mainModelProvider.isEmpty() && state.providers.isEmpty() &&
|
||||||
|
state.pinnedModels.isEmpty() -> {
|
||||||
SkeletonListState(modifier = Modifier.padding(paddingValues))
|
SkeletonListState(modifier = Modifier.padding(paddingValues))
|
||||||
}
|
}
|
||||||
|
|
||||||
state.errorMessage != null && state.providers.isEmpty() && state.mainModelProvider.isEmpty() -> {
|
state.errorMessage != null && state.providers.isEmpty() && state.mainModelProvider.isEmpty() &&
|
||||||
|
state.pinnedModels.isEmpty() -> {
|
||||||
ErrorState(
|
ErrorState(
|
||||||
message = state.errorMessage ?: "",
|
message = state.errorMessage ?: "",
|
||||||
onRetry = { viewModel.loadAll() },
|
onRetry = { viewModel.loadAll() },
|
||||||
@@ -119,7 +121,8 @@ fun ModelScreen(
|
|||||||
!state.isLoading &&
|
!state.isLoading &&
|
||||||
!state.catalogLoading &&
|
!state.catalogLoading &&
|
||||||
state.providers.isEmpty() &&
|
state.providers.isEmpty() &&
|
||||||
state.mainModelProvider.isEmpty() -> {
|
state.mainModelProvider.isEmpty() &&
|
||||||
|
state.pinnedModels.isEmpty() -> {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = stringResource(R.string.model_empty_title),
|
title = stringResource(R.string.model_empty_title),
|
||||||
subtitle = stringResource(R.string.model_empty_desc),
|
subtitle = stringResource(R.string.model_empty_desc),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
|
|||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
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.ModelOptionsRepository
|
import com.m57.hermescontrol.data.ws.ModelOptionsRepository
|
||||||
import com.m57.hermescontrol.ui.common.ToastHost
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -69,14 +70,28 @@ data class ModelUiState(
|
|||||||
)
|
)
|
||||||
|
|
||||||
class ModelViewModel(
|
class ModelViewModel(
|
||||||
private val modelOptionsRepository: ModelOptionsRepository = ModelOptionsRepository(),
|
private val catalogStore: ModelCatalogStore = ModelCatalogStore.shared,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
|
constructor(repository: ModelOptionsRepository) : this(ModelCatalogStore(repository = repository))
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ModelUiState())
|
private val _uiState = MutableStateFlow(ModelUiState())
|
||||||
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
_uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) }
|
_uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
catalogStore.state.collect { catalogState ->
|
||||||
|
// Empty state is meaningful: it is how ModelCatalogStore
|
||||||
|
// invalidates the previous DataScope. Never ignore it.
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
providers = catalogState.providers,
|
||||||
|
catalogLoading = catalogState.isRefreshing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadAll(refresh: Boolean = false) {
|
fun loadAll(refresh: Boolean = false) {
|
||||||
@@ -100,10 +115,10 @@ class ModelViewModel(
|
|||||||
safeApiCall { ApiClient.hermesApi.getMoaModels() }
|
safeApiCall { ApiClient.hermesApi.getMoaModels() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Launch model options concurrently
|
// Phase 2: Launch model options concurrently via shared catalog store
|
||||||
val optionsDeferred =
|
val optionsDeferred =
|
||||||
async(Dispatchers.IO) {
|
async {
|
||||||
modelOptionsRepository.load(refresh)
|
catalogStore.ensureLoaded(forceRefresh = refresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Await Phase 1 calls (~40-190ms total) and update UI immediately with available state
|
// Await Phase 1 calls (~40-190ms total) and update UI immediately with available state
|
||||||
@@ -160,12 +175,9 @@ class ModelViewModel(
|
|||||||
val optionsResult = optionsDeferred.await()
|
val optionsResult = optionsDeferred.await()
|
||||||
when (optionsResult) {
|
when (optionsResult) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update {
|
// ModelCatalogStore.state is the single source of truth.
|
||||||
it.copy(
|
// Do not independently republish a result here because a
|
||||||
catalogLoading = false,
|
// DataScope switch may have invalidated it meanwhile.
|
||||||
providers = optionsResult.data.providers.orEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ fun ModelPickerDialog(
|
|||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
if (isLoading) {
|
if (isLoading && providers.isEmpty() && pinnedModels.isEmpty()) {
|
||||||
LoadingState(
|
LoadingState(
|
||||||
subtitle = stringResource(R.string.model_picker_loading),
|
subtitle = stringResource(R.string.model_picker_loading),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.DataScope
|
||||||
|
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.remote.NetworkResult
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ModelCatalogStoreTest {
|
||||||
|
private val testDispatcher = UnconfinedTestDispatcher()
|
||||||
|
private val testScope = TestScope(testDispatcher)
|
||||||
|
|
||||||
|
private val sampleScope =
|
||||||
|
DataScope(
|
||||||
|
connectionProfileId = "conn-1",
|
||||||
|
baseUrl = "http://localhost:9119",
|
||||||
|
activeProfileId = "default",
|
||||||
|
inMemoryAuthGeneration = 1L,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val fakeResponse =
|
||||||
|
ModelOptionsResponse(
|
||||||
|
providers =
|
||||||
|
listOf(
|
||||||
|
ModelProvider(
|
||||||
|
slug = "openai",
|
||||||
|
name = "OpenAI",
|
||||||
|
models = listOf("gpt-4o"),
|
||||||
|
capabilities = mapOf("gpt-4o" to ModelCapabilities(fast = true)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two concurrent callers share single in-flight load`() =
|
||||||
|
testScope.runTest {
|
||||||
|
var loadCalls = 0
|
||||||
|
val gate = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
val repo =
|
||||||
|
ModelOptionsRepository(
|
||||||
|
connected = { false },
|
||||||
|
rest = {
|
||||||
|
loadCalls++
|
||||||
|
gate.await()
|
||||||
|
NetworkResult.Success(fakeResponse)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val store =
|
||||||
|
ModelCatalogStore(
|
||||||
|
repository = repo,
|
||||||
|
getCurrentScope = { sampleScope },
|
||||||
|
scope = testScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
coroutineScope {
|
||||||
|
val deferred1 = async { store.ensureLoaded(forceRefresh = false) }
|
||||||
|
val deferred2 = async { store.ensureLoaded(forceRefresh = false) }
|
||||||
|
|
||||||
|
assertEquals(1, loadCalls)
|
||||||
|
gate.complete(Unit)
|
||||||
|
deferred1.await()
|
||||||
|
deferred2.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
val state = store.state.value
|
||||||
|
assertEquals(1, state.providers.size)
|
||||||
|
assertTrue(state.hasLoaded)
|
||||||
|
assertFalse(state.isRefreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fresh cache returns immediately without extra network call`() =
|
||||||
|
testScope.runTest {
|
||||||
|
var loadCalls = 0
|
||||||
|
var now = 1000L
|
||||||
|
|
||||||
|
val repo =
|
||||||
|
ModelOptionsRepository(
|
||||||
|
connected = { false },
|
||||||
|
rest = {
|
||||||
|
loadCalls++
|
||||||
|
NetworkResult.Success(fakeResponse)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val store =
|
||||||
|
ModelCatalogStore(
|
||||||
|
repository = repo,
|
||||||
|
getCurrentScope = { sampleScope },
|
||||||
|
clock = { now },
|
||||||
|
scope = testScope,
|
||||||
|
ttlMs = 60_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
store.ensureLoaded(forceRefresh = false)
|
||||||
|
assertEquals(1, loadCalls)
|
||||||
|
|
||||||
|
// Advance clock by 10s (well within 60s TTL)
|
||||||
|
now += 10_000L
|
||||||
|
store.ensureLoaded(forceRefresh = false)
|
||||||
|
assertEquals(1, loadCalls)
|
||||||
|
|
||||||
|
// Force refresh should bypass TTL
|
||||||
|
store.ensureLoaded(forceRefresh = true)
|
||||||
|
assertEquals(2, loadCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `forced refresh supersedes normal load without cancelling existing callers`() =
|
||||||
|
testScope.runTest {
|
||||||
|
val normalGate = CompletableDeferred<Unit>()
|
||||||
|
val forcedGate = CompletableDeferred<Unit>()
|
||||||
|
val requests = mutableListOf<Boolean>()
|
||||||
|
|
||||||
|
val forcedResponse =
|
||||||
|
ModelOptionsResponse(
|
||||||
|
providers =
|
||||||
|
listOf(
|
||||||
|
ModelProvider(
|
||||||
|
slug = "anthropic",
|
||||||
|
name = "Anthropic",
|
||||||
|
models = listOf("claude-sonnet"),
|
||||||
|
capabilities = emptyMap(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val repo =
|
||||||
|
ModelOptionsRepository(
|
||||||
|
connected = { false },
|
||||||
|
rest = { refresh ->
|
||||||
|
requests += refresh
|
||||||
|
if (refresh) {
|
||||||
|
forcedGate.await()
|
||||||
|
NetworkResult.Success(forcedResponse)
|
||||||
|
} else {
|
||||||
|
normalGate.await()
|
||||||
|
NetworkResult.Success(fakeResponse)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val store =
|
||||||
|
ModelCatalogStore(
|
||||||
|
repository = repo,
|
||||||
|
getCurrentScope = { sampleScope },
|
||||||
|
scope = testScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
coroutineScope {
|
||||||
|
val normalCaller = async { store.ensureLoaded(forceRefresh = false) }
|
||||||
|
assertEquals(listOf(false), requests)
|
||||||
|
|
||||||
|
val forcedCaller = async { store.ensureLoaded(forceRefresh = true) }
|
||||||
|
assertEquals(listOf(false, true), requests)
|
||||||
|
|
||||||
|
// The superseded physical normal request may finish, but its
|
||||||
|
// callers remain attached to the logical request and wait for
|
||||||
|
// the authoritative forced result.
|
||||||
|
normalGate.complete(Unit)
|
||||||
|
assertFalse(normalCaller.isCancelled)
|
||||||
|
assertFalse(normalCaller.isCompleted)
|
||||||
|
|
||||||
|
forcedGate.complete(Unit)
|
||||||
|
|
||||||
|
val normalResult = normalCaller.await() as NetworkResult.Success
|
||||||
|
val forcedResult = forcedCaller.await() as NetworkResult.Success
|
||||||
|
|
||||||
|
assertEquals(forcedResponse.providers, normalResult.data.providers)
|
||||||
|
assertEquals(forcedResponse.providers, forcedResult.data.providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(forcedResponse.providers, store.state.value.providers)
|
||||||
|
assertTrue(store.state.value.hasLoaded)
|
||||||
|
assertFalse(store.state.value.isRefreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scope change invalidates cached state and cancels in-flight work`() =
|
||||||
|
testScope.runTest {
|
||||||
|
var currentScope = sampleScope
|
||||||
|
val repo =
|
||||||
|
ModelOptionsRepository(
|
||||||
|
connected = { false },
|
||||||
|
rest = { NetworkResult.Success(fakeResponse) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val store =
|
||||||
|
ModelCatalogStore(
|
||||||
|
repository = repo,
|
||||||
|
getCurrentScope = { currentScope },
|
||||||
|
scope = testScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
store.ensureLoaded(forceRefresh = false)
|
||||||
|
assertTrue(store.state.value.hasLoaded)
|
||||||
|
assertEquals(1, store.state.value.providers.size)
|
||||||
|
|
||||||
|
// Switch scope
|
||||||
|
val otherScope = sampleScope.copy(activeProfileId = "work")
|
||||||
|
currentScope = otherScope
|
||||||
|
store.onScopeChanged(otherScope)
|
||||||
|
|
||||||
|
assertEquals(0, store.state.value.providers.size)
|
||||||
|
assertFalse(store.state.value.hasLoaded)
|
||||||
|
assertEquals(otherScope, store.state.value.scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.DataScope
|
||||||
import com.m57.hermescontrol.data.model.ModelCapabilities
|
import com.m57.hermescontrol.data.model.ModelCapabilities
|
||||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
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.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.ws.WsMethods
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -32,6 +36,19 @@ class ChatModelSwitchDelegateTest {
|
|||||||
private var contextRefetched = 0
|
private var contextRefetched = 0
|
||||||
private var modelSwitchInitiated = 0
|
private var modelSwitchInitiated = 0
|
||||||
private var pinnedList = mutableListOf(PinnedModel("anthropic", "claude-3"))
|
private var pinnedList = mutableListOf(PinnedModel("anthropic", "claude-3"))
|
||||||
|
private val modelRequests = mutableListOf<Boolean>()
|
||||||
|
private var modelResponseGate: CompletableDeferred<Unit>? = null
|
||||||
|
private var modelFailure: NetworkResult.Failure? = null
|
||||||
|
|
||||||
|
private val initialDataScope =
|
||||||
|
DataScope(
|
||||||
|
connectionProfileId = "conn-1",
|
||||||
|
baseUrl = "http://localhost:9119",
|
||||||
|
activeProfileId = "default",
|
||||||
|
inMemoryAuthGeneration = 1L,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val dataScope = MutableStateFlow<DataScope?>(initialDataScope)
|
||||||
|
|
||||||
private val fakeResponse =
|
private val fakeResponse =
|
||||||
ModelOptionsResponse(
|
ModelOptionsResponse(
|
||||||
@@ -62,7 +79,12 @@ class ChatModelSwitchDelegateTest {
|
|||||||
handleSlashCommand = { slashCommands.add(it) },
|
handleSlashCommand = { slashCommands.add(it) },
|
||||||
fetchContextUsage = { contextRefetched++ },
|
fetchContextUsage = { contextRefetched++ },
|
||||||
onModelSwitchInitiated = { modelSwitchInitiated++ },
|
onModelSwitchInitiated = { modelSwitchInitiated++ },
|
||||||
getModelOptionsCall = { NetworkResult.Success(fakeResponse) },
|
dataScopeFlow = dataScope,
|
||||||
|
getModelOptionsCall = { refresh ->
|
||||||
|
modelRequests.add(refresh)
|
||||||
|
modelResponseGate?.await()
|
||||||
|
modelFailure ?: NetworkResult.Success(fakeResponse)
|
||||||
|
},
|
||||||
getPinnedModels = { pinnedList },
|
getPinnedModels = { pinnedList },
|
||||||
savePinnedModels = { pinnedList = it.toMutableList() },
|
savePinnedModels = { pinnedList = it.toMutableList() },
|
||||||
)
|
)
|
||||||
@@ -88,6 +110,131 @@ class ChatModelSwitchDelegateTest {
|
|||||||
assertFalse(uiState.value.modelPickerLoading)
|
assertFalse(uiState.value.modelPickerLoading)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun openModelPicker_duringPreload_reusesRequestAndPublishesResult() =
|
||||||
|
testScope.runTest {
|
||||||
|
modelResponseGate = CompletableDeferred()
|
||||||
|
delegate.preloadModelOptions()
|
||||||
|
runCurrent()
|
||||||
|
delegate.openModelPicker()
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val requestsWhileLoading = modelRequests.toList()
|
||||||
|
modelResponseGate?.complete(Unit)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(listOf(false), requestsWhileLoading)
|
||||||
|
assertEquals(fakeResponse.providers, uiState.value.modelPickerProviders)
|
||||||
|
assertFalse(uiState.value.modelPickerLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun coldOpen_usesServerCache_andReopenUsesMemoryCache() =
|
||||||
|
testScope.runTest {
|
||||||
|
delegate.openModelPicker()
|
||||||
|
advanceUntilIdle()
|
||||||
|
delegate.closeModelPicker()
|
||||||
|
delegate.openModelPicker()
|
||||||
|
assertFalse(uiState.value.modelPickerLoading)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(listOf(false), modelRequests)
|
||||||
|
assertEquals(fakeResponse.providers, uiState.value.modelPickerProviders)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scopeSwitch_clearsCachedModelsAndReloadsOpenPicker() =
|
||||||
|
testScope.runTest {
|
||||||
|
val scopeObserverJob = kotlinx.coroutines.Job()
|
||||||
|
val observerScope = kotlinx.coroutines.CoroutineScope(testDispatcher + scopeObserverJob)
|
||||||
|
delegate.attachScopeObserver(observerScope)
|
||||||
|
|
||||||
|
delegate.preloadModelOptions()
|
||||||
|
advanceUntilIdle()
|
||||||
|
delegate.openModelPicker()
|
||||||
|
|
||||||
|
assertEquals(fakeResponse.providers, uiState.value.modelPickerProviders)
|
||||||
|
assertFalse(uiState.value.modelPickerLoading)
|
||||||
|
|
||||||
|
modelResponseGate = CompletableDeferred()
|
||||||
|
dataScope.value = initialDataScope.copy(activeProfileId = "work")
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
// Previous-scope rows disappear immediately, before the new
|
||||||
|
// catalog request is allowed to complete.
|
||||||
|
assertTrue(uiState.value.showModelPicker)
|
||||||
|
assertTrue(uiState.value.modelPickerProviders.isEmpty())
|
||||||
|
assertTrue(uiState.value.modelPickerLoading)
|
||||||
|
assertEquals(listOf(false, false), modelRequests)
|
||||||
|
|
||||||
|
modelResponseGate?.complete(Unit)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(fakeResponse.providers, uiState.value.modelPickerProviders)
|
||||||
|
assertFalse(uiState.value.modelPickerLoading)
|
||||||
|
|
||||||
|
scopeObserverJob.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun closeAndReopenDuringPreload_doesNotDuplicateOrReopenAfterDismiss() =
|
||||||
|
testScope.runTest {
|
||||||
|
modelResponseGate = CompletableDeferred()
|
||||||
|
delegate.preloadModelOptions()
|
||||||
|
runCurrent()
|
||||||
|
delegate.openModelPicker()
|
||||||
|
delegate.closeModelPicker()
|
||||||
|
delegate.openModelPicker()
|
||||||
|
delegate.closeModelPicker()
|
||||||
|
runCurrent()
|
||||||
|
modelResponseGate?.complete(Unit)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(listOf(false), modelRequests)
|
||||||
|
assertFalse(uiState.value.showModelPicker)
|
||||||
|
assertFalse(uiState.value.modelPickerLoading)
|
||||||
|
assertEquals(fakeResponse.providers, uiState.value.modelPickerProviders)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun explicitRefresh_supersedesPreload() =
|
||||||
|
testScope.runTest {
|
||||||
|
modelResponseGate = CompletableDeferred()
|
||||||
|
delegate.preloadModelOptions()
|
||||||
|
runCurrent()
|
||||||
|
delegate.refreshModelOptions()
|
||||||
|
runCurrent()
|
||||||
|
modelResponseGate?.complete(Unit)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(listOf(false, true), modelRequests)
|
||||||
|
assertEquals(fakeResponse.providers, uiState.value.modelPickerProviders)
|
||||||
|
assertFalse(uiState.value.modelPickerLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failedPreload_isSilentAndCanRetryOnOpen() =
|
||||||
|
testScope.runTest {
|
||||||
|
modelFailure = NetworkResult.Failure(NetworkError.Http(503, "Unavailable"))
|
||||||
|
delegate.preloadModelOptions()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertNull(uiState.value.errorMessage)
|
||||||
|
assertFalse(uiState.value.modelPickerLoading)
|
||||||
|
|
||||||
|
modelFailure = null
|
||||||
|
delegate.openModelPicker()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(listOf(false, false), modelRequests)
|
||||||
|
assertEquals(fakeResponse.providers, uiState.value.modelPickerProviders)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failedOpen_clearsLoadingAndReportsError() =
|
||||||
|
testScope.runTest {
|
||||||
|
modelFailure = NetworkResult.Failure(NetworkError.Http(503, "Unavailable"))
|
||||||
|
delegate.openModelPicker()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertFalse(uiState.value.modelPickerLoading)
|
||||||
|
assertEquals("Failed to load models: Unavailable", uiState.value.errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sendSlashModel_optimisticallyUpdatesModelAndDispatchesSlash() {
|
fun sendSlashModel_optimisticallyUpdatesModelAndDispatchesSlash() {
|
||||||
delegate.sendSlashModel("openai", "gpt-4o")
|
delegate.sendSlashModel("openai", "gpt-4o")
|
||||||
@@ -261,33 +408,49 @@ class ChatModelSwitchDelegateTest {
|
|||||||
id = "req-1",
|
id = "req-1",
|
||||||
result = mapOf("key" to "model", "confirm_required" to true),
|
result = mapOf("key" to "model", "confirm_required" to true),
|
||||||
)
|
)
|
||||||
// Guarded: sequence 1 != current sequence 2, so confirmation is ignored
|
|
||||||
assertNull(uiState.value.modelSwitchConfirmMessage)
|
assertNull(uiState.value.modelSwitchConfirmMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handleConfigSetError_staleSequence_isIgnored() =
|
||||||
|
testScope.runTest {
|
||||||
|
delegate.sendSlashModel("openai", "gpt-4o")
|
||||||
|
advanceUntilIdle()
|
||||||
|
delegate.handleModelSwitch("/model gpt-4o --provider openai --session")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Second switch arrives
|
||||||
|
delegate.sendSlashModel("anthropic", "claude-3-5-sonnet")
|
||||||
|
advanceUntilIdle()
|
||||||
|
delegate.handleModelSwitch("/model claude-3-5-sonnet --provider anthropic --session")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// First switch fails — should not roll back the second switch
|
||||||
|
delegate.handleConfigSetError("req-1", "First switch failed")
|
||||||
|
assertEquals("anthropic/claude-3-5-sonnet", uiState.value.currentSessionModel)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun rapidPicks_preservesOriginalConfirmedModelForRollback() =
|
fun rapidPicks_preservesOriginalConfirmedModelForRollback() =
|
||||||
testScope.runTest {
|
testScope.runTest {
|
||||||
// Initially confirmed model is anthropic/claude-3
|
// Pick model A
|
||||||
delegate.onModelConfirmed("anthropic/claude-3")
|
|
||||||
uiState.value = uiState.value.copy(currentSessionModel = "anthropic/claude-3")
|
|
||||||
|
|
||||||
// Pick 1: switch to gpt-4o
|
|
||||||
delegate.sendSlashModel("openai", "gpt-4o")
|
delegate.sendSlashModel("openai", "gpt-4o")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
delegate.handleModelSwitch("/model gpt-4o --provider openai --session")
|
delegate.handleModelSwitch("/model gpt-4o --provider openai --session")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals("openai/gpt-4o", uiState.value.currentSessionModel)
|
|
||||||
|
|
||||||
// Pick 2: rapidly pick solar without gpt-4o ever confirming
|
// Pick model B before A confirms
|
||||||
delegate.sendSlashModel("nous", "solar")
|
delegate.sendSlashModel("anthropic", "claude-3-5-sonnet")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
delegate.handleModelSwitch("/model solar --provider nous --session")
|
delegate.handleModelSwitch("/model claude-3-5-sonnet --provider anthropic --session")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals("nous/solar", uiState.value.currentSessionModel)
|
|
||||||
|
|
||||||
// Error on second switch (req-2) should roll back to confirmed model, not optimistic gpt-4o
|
// Dismiss confirmation on B — should revert to original model before both switches
|
||||||
delegate.handleConfigSetError("req-2", "Solar failed")
|
delegate.handleConfigSetResult(
|
||||||
|
id = "req-2",
|
||||||
|
result = mapOf("key" to "model", "confirm_required" to true),
|
||||||
|
)
|
||||||
|
delegate.dismissModelSwitchConfirm()
|
||||||
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
|
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
package com.m57.hermescontrol.ui.model
|
package com.m57.hermescontrol.ui.model
|
||||||
|
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.local.DataScope
|
||||||
|
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.model.PinnedModel
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import com.m57.hermescontrol.data.ws.ModelCatalogStore
|
||||||
|
import com.m57.hermescontrol.data.ws.ModelOptionsRepository
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockkObject
|
import io.mockk.mockkObject
|
||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
@@ -11,6 +17,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
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
|
||||||
@@ -148,6 +155,57 @@ class ModelViewModelTest {
|
|||||||
verify { AuthManager.savePinnedModels(listOf(PinnedModel("anthropic", "claude-3"))) }
|
verify { AuthManager.savePinnedModels(listOf(PinnedModel("anthropic", "claude-3"))) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `catalog scope reset clears providers from previous scope`() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
var currentScope =
|
||||||
|
DataScope(
|
||||||
|
connectionProfileId = "conn-1",
|
||||||
|
baseUrl = "http://localhost:9119",
|
||||||
|
activeProfileId = "default",
|
||||||
|
inMemoryAuthGeneration = 1L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val response =
|
||||||
|
ModelOptionsResponse(
|
||||||
|
providers =
|
||||||
|
listOf(
|
||||||
|
ModelProvider(
|
||||||
|
slug = "openai",
|
||||||
|
name = "OpenAI",
|
||||||
|
models = listOf("gpt-4o"),
|
||||||
|
capabilities = emptyMap(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val store =
|
||||||
|
ModelCatalogStore(
|
||||||
|
repository =
|
||||||
|
ModelOptionsRepository(
|
||||||
|
connected = { false },
|
||||||
|
rest = { NetworkResult.Success(response) },
|
||||||
|
),
|
||||||
|
getCurrentScope = { currentScope },
|
||||||
|
scope = this,
|
||||||
|
)
|
||||||
|
|
||||||
|
val viewModel = ModelViewModel(catalogStore = store)
|
||||||
|
|
||||||
|
store.ensureLoaded()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(response.providers, viewModel.uiState.value.providers)
|
||||||
|
|
||||||
|
currentScope = currentScope.copy(activeProfileId = "work")
|
||||||
|
store.onScopeChanged(currentScope)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
viewModel.uiState.value.providers
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `pinModel shows toast when max cap reached`() {
|
fun `pinModel shows toast when max cap reached`() {
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
|
|||||||
Reference in new issue
Block a user