Merge pull request #1203 from Hy4ri/dev

chore(release): merge dev to main
This commit is contained in:
m57 authored and GitHub committed 2026-09-20 13:00:51 +03:00
commit cd723db87d
10 files changed
+853 -61

No files matched your search

@@ -26,6 +26,25 @@ class ModelPickerDialogTest {
@get:Rule
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
fun providers_startCollapsed_andOnlyOneProviderExpands() {
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
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.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.ModelCatalogStore
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
/**
@@ -46,10 +51,11 @@ class ChatModelSwitchDelegate(
private val handleSlashCommand: (command: String) -> Unit,
private val fetchContextUsage: () -> Unit,
private val onModelSwitchInitiated: () -> Unit = {},
private val dataScopeFlow: StateFlow<DataScope?> = AuthManager.dataScopeFlow,
private val getModelOptionsCall: suspend (
refresh: Boolean,
) -> 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 savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) },
) {
@@ -62,6 +68,43 @@ class ChatModelSwitchDelegate(
private var unconfirmedTargetModel: String? = null
private var modelSwitchSequence: Long = 0L
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 {
val trimmed = command.trim()
@@ -75,18 +118,13 @@ class ChatModelSwitchDelegate(
}
fun preloadModelOptions() {
scope.launch(ioDispatcher) {
val result = getModelOptionsCall(false)
if (result is NetworkResult.Success) {
cachedModelOptions = result.data.providers.orEmpty()
uiState.update { it.copy(modelPickerPinned = getPinnedModels()) }
syncCurrentModelCapabilities()
}
}
loadModelOptions(refresh = false)
}
fun openModelPicker() {
val hasCached = cachedModelOptions.isNotEmpty()
val currentScope = dataScopeFlow.value
val hasCached =
cachedModelOptionsScope == currentScope && cachedModelOptions.isNotEmpty()
uiState.update {
it.copy(
showModelPicker = true,
@@ -96,36 +134,77 @@ class ChatModelSwitchDelegate(
)
}
if (!hasCached) {
refreshModelOptions()
loadModelOptions(refresh = false)
}
}
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) }
scope.launch(ioDispatcher) {
val result = getModelOptionsCall(true)
when (result) {
is NetworkResult.Success -> {
cachedModelOptions = result.data.providers.orEmpty()
uiState.update {
it.copy(
modelPickerProviders = cachedModelOptions,
modelPickerLoading = false,
)
}
syncCurrentModelCapabilities()
modelOptionsJobScope = requestScope
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
}
is NetworkResult.Failure -> {
uiState.update {
it.copy(
modelPickerLoading = false,
errorMessage = "Failed to load models: ${result.error.message}",
)
when (result) {
is NetworkResult.Success -> {
cachedModelOptions = result.data.providers.orEmpty()
cachedModelOptionsScope = requestScope
uiState.update {
it.copy(
modelPickerProviders = cachedModelOptions,
modelPickerPinned = getPinnedModels(),
modelPickerLoading = false,
)
}
syncCurrentModelCapabilities()
}
is NetworkResult.Failure -> {
uiState.update {
it.copy(
modelPickerLoading = false,
errorMessage =
if (it.showModelPicker || refresh) {
"Failed to load models: ${result.error.message}"
} else {
it.errorMessage
},
)
}
}
}
}
}
}
fun closeModelPicker() {
@@ -402,7 +481,13 @@ class ChatModelSwitchDelegate(
fun getModelCapabilities(
providerSlug: 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? {
val label = uiState.value.currentSessionModel ?: return null
@@ -425,7 +510,7 @@ class ChatModelSwitchDelegate(
} else {
val provider = label.substring(0, idx)
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) {
baseCaps.copy(fast = false)
} else {
@@ -479,7 +479,9 @@ class ChatViewModel(
handleSlashCommand = { cmd -> handleSlashCommand(cmd) },
fetchContextUsage = { fetchContextUsage() },
onModelSwitchInitiated = { onModelSwitchInitiated() },
)
).apply {
attachScopeObserver(viewModelScope)
}
private val credentialPromptsDelegate =
ChatCredentialPromptsDelegate(
@@ -104,11 +104,13 @@ fun ModelScreen(
onRefresh = { viewModel.loadAll(refresh = true) },
) { paddingValues ->
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))
}
state.errorMessage != null && state.providers.isEmpty() && state.mainModelProvider.isEmpty() -> {
state.errorMessage != null && state.providers.isEmpty() && state.mainModelProvider.isEmpty() &&
state.pinnedModels.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadAll() },
@@ -119,7 +121,8 @@ fun ModelScreen(
!state.isLoading &&
!state.catalogLoading &&
state.providers.isEmpty() &&
state.mainModelProvider.isEmpty() -> {
state.mainModelProvider.isEmpty() &&
state.pinnedModels.isEmpty() -> {
EmptyState(
title = stringResource(R.string.model_empty_title),
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.NetworkResult
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.ui.common.ToastHost
import kotlinx.coroutines.Dispatchers
@@ -69,14 +70,28 @@ data class ModelUiState(
)
class ModelViewModel(
private val modelOptionsRepository: ModelOptionsRepository = ModelOptionsRepository(),
private val catalogStore: ModelCatalogStore = ModelCatalogStore.shared,
) : ViewModel(),
ToastHost {
constructor(repository: ModelOptionsRepository) : this(ModelCatalogStore(repository = repository))
private val _uiState = MutableStateFlow(ModelUiState())
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
init {
_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) {
@@ -100,10 +115,10 @@ class ModelViewModel(
safeApiCall { ApiClient.hermesApi.getMoaModels() }
}
// Phase 2: Launch model options concurrently
// Phase 2: Launch model options concurrently via shared catalog store
val optionsDeferred =
async(Dispatchers.IO) {
modelOptionsRepository.load(refresh)
async {
catalogStore.ensureLoaded(forceRefresh = refresh)
}
// Await Phase 1 calls (~40-190ms total) and update UI immediately with available state
@@ -160,12 +175,9 @@ class ModelViewModel(
val optionsResult = optionsDeferred.await()
when (optionsResult) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
catalogLoading = false,
providers = optionsResult.data.providers.orEmpty(),
)
}
// ModelCatalogStore.state is the single source of truth.
// Do not independently republish a result here because a
// DataScope switch may have invalidated it meanwhile.
}
is NetworkResult.Failure -> {
@@ -102,7 +102,7 @@ fun ModelPickerDialog(
},
text = {
Column {
if (isLoading) {
if (isLoading && providers.isEmpty() && pinnedModels.isEmpty()) {
LoadingState(
subtitle = stringResource(R.string.model_picker_loading),
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
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.model.PinnedModel
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -32,6 +36,19 @@ class ChatModelSwitchDelegateTest {
private var contextRefetched = 0
private var modelSwitchInitiated = 0
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 =
ModelOptionsResponse(
@@ -62,7 +79,12 @@ class ChatModelSwitchDelegateTest {
handleSlashCommand = { slashCommands.add(it) },
fetchContextUsage = { contextRefetched++ },
onModelSwitchInitiated = { modelSwitchInitiated++ },
getModelOptionsCall = { NetworkResult.Success(fakeResponse) },
dataScopeFlow = dataScope,
getModelOptionsCall = { refresh ->
modelRequests.add(refresh)
modelResponseGate?.await()
modelFailure ?: NetworkResult.Success(fakeResponse)
},
getPinnedModels = { pinnedList },
savePinnedModels = { pinnedList = it.toMutableList() },
)
@@ -88,6 +110,131 @@ class ChatModelSwitchDelegateTest {
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
fun sendSlashModel_optimisticallyUpdatesModelAndDispatchesSlash() {
delegate.sendSlashModel("openai", "gpt-4o")
@@ -261,33 +408,49 @@ class ChatModelSwitchDelegateTest {
id = "req-1",
result = mapOf("key" to "model", "confirm_required" to true),
)
// Guarded: sequence 1 != current sequence 2, so confirmation is ignored
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
fun rapidPicks_preservesOriginalConfirmedModelForRollback() =
testScope.runTest {
// Initially confirmed model is anthropic/claude-3
delegate.onModelConfirmed("anthropic/claude-3")
uiState.value = uiState.value.copy(currentSessionModel = "anthropic/claude-3")
// Pick 1: switch to gpt-4o
// Pick model A
delegate.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle()
delegate.handleModelSwitch("/model gpt-4o --provider openai --session")
advanceUntilIdle()
assertEquals("openai/gpt-4o", uiState.value.currentSessionModel)
// Pick 2: rapidly pick solar without gpt-4o ever confirming
delegate.sendSlashModel("nous", "solar")
// Pick model B before A confirms
delegate.sendSlashModel("anthropic", "claude-3-5-sonnet")
advanceUntilIdle()
delegate.handleModelSwitch("/model solar --provider nous --session")
delegate.handleModelSwitch("/model claude-3-5-sonnet --provider anthropic --session")
advanceUntilIdle()
assertEquals("nous/solar", uiState.value.currentSessionModel)
// Error on second switch (req-2) should roll back to confirmed model, not optimistic gpt-4o
delegate.handleConfigSetError("req-2", "Solar failed")
// Dismiss confirmation on B — should revert to original model before both switches
delegate.handleConfigSetResult(
id = "req-2",
result = mapOf("key" to "model", "confirm_required" to true),
)
delegate.dismissModelSwitchConfirm()
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
}
}
@@ -1,7 +1,13 @@
package com.m57.hermescontrol.ui.model
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.remote.NetworkResult
import com.m57.hermescontrol.data.ws.ModelCatalogStore
import com.m57.hermescontrol.data.ws.ModelOptionsRepository
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkAll
@@ -11,6 +17,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
@@ -148,6 +155,57 @@ class ModelViewModelTest {
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
fun `pinModel shows toast when max cap reached`() {
val viewModel = createViewModel()