From be6104b90307f56c15f1a6a3278d188fcd5d5d55 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:27:05 +0300 Subject: [PATCH 1/2] refactor(#1164): load model inventory over WS with REST fallback --- .../data/ws/ModelOptionsRepository.kt | 50 ++++++++++ .../m57/hermescontrol/data/ws/WsMethods.kt | 2 + .../hermescontrol/ui/model/ModelViewModel.kt | 8 +- .../data/ws/ModelOptionsRepositoryTest.kt | 96 +++++++++++++++++++ 4 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt new file mode 100644 index 00000000..90cd65b8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt @@ -0,0 +1,50 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ModelOptionsResponse +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.safeApiCall +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement + +/** Issue #1164: profile-configured inventory; never pass a live chat session override. */ +class ModelOptionsRepository( + private val connected: () -> Boolean = { + HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED + }, + private val request: suspend (Map) -> Any? = { params -> + val deferred = HermesWsClient.request(WsMethods.MODEL_OPTIONS, params) + try { + deferred.await() + } finally { + if (!deferred.isCompleted) deferred.cancel() + } + }, + private val rest: suspend (Boolean) -> NetworkResult = { refresh -> + safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) } + }, +) { + suspend fun load(refresh: Boolean = false): NetworkResult { + currentCoroutineContext().ensureActive() + if (connected()) { + try { + val result = request(mapOf("refresh" to refresh, "include_unconfigured" to false)) + // request() returns raw JsonElement, unlike the pushed-event Map representation. + val json = result as? JsonElement ?: error("Invalid model.options result") + return NetworkResult.Success(OkHttpProvider.json.decodeFromJsonElement(json)) + } catch (e: CancellationException) { + // A disconnected socket may cancel the independent RPC deferred. Only fall back + // if the screen's own coroutine is still active; never swallow caller cancellation. + currentCoroutineContext().ensureActive() + } catch (_: Exception) { + // Older gateways and malformed payloads keep the existing HTTP behavior. + } + } + currentCoroutineContext().ensureActive() + return rest(refresh) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index ab8c47bc..45727e0a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -28,6 +28,7 @@ object WsMethods { const val SESSION_USAGE = "session.usage" const val PROFILES_CONFIGURE = "profiles.configure" const val PROFILES_LIST = "profiles.list" + const val MODEL_OPTIONS = "model.options" // ── Projects ────────────────────────────────────────────────────────── @@ -154,6 +155,7 @@ object WsMethods { // Evidence: methods_session.py lines 42, 164, 232, 317, 845 SESSION_CREATE, // session.create SESSION_LIST, // session.list + MODEL_OPTIONS, // model.options: @_profile_scoped SESSION_RESUME, // session.resume SESSION_DELETE, // session.delete SESSION_STATUS, // session.status diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt index c3f67177..b7c82a4f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt @@ -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.ModelOptionsRepository import com.m57.hermescontrol.ui.common.ToastHost import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -66,8 +67,9 @@ data class ModelUiState( val pendingModelPickerResolve: ((Boolean) -> Unit)? = null, ) -class ModelViewModel : - ViewModel(), +class ModelViewModel( + private val modelOptionsRepository: ModelOptionsRepository = ModelOptionsRepository(), +) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(ModelUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -100,7 +102,7 @@ class ModelViewModel : // Phase 2: Launch model options concurrently val optionsDeferred = async(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) } + modelOptionsRepository.load(refresh) } // Await Phase 1 calls (~40-190ms total) and update UI immediately with available state diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt new file mode 100644 index 00000000..2bff3c55 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt @@ -0,0 +1,96 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ModelOptionsResponse +import com.m57.hermescontrol.data.remote.NetworkResult +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ModelOptionsRepositoryTest { + private val fallback = NetworkResult.Success(ModelOptionsResponse(emptyList())) + + @Test + fun `connected uses raw JSON and preserves refresh flags without REST`() = + runTest { + val repository = + ModelOptionsRepository( + connected = { true }, + request = { params -> + assertEquals(mapOf("refresh" to true, "include_unconfigured" to false), params) + Json.parseToJsonElement("""{"providers":[{"slug":"test","name":"Test","models":["a"]}]}""") + }, + rest = { error("REST must not run") }, + ) + val result = repository.load(true) as NetworkResult.Success + assertEquals( + "test", + result.data.providers + .single() + .slug, + ) + assertTrue(WsMethods.MODEL_OPTIONS in WsMethods.PROFILE_SCOPED_METHODS) + } + + @Test + fun `disconnected bypasses RPC`() = + runTest { + val repository = + ModelOptionsRepository( + connected = { false }, + request = { error("RPC must not run") }, + rest = { refresh -> + assertTrue(refresh) + fallback + }, + ) + assertEquals(fallback, repository.load(true)) + } + + @Test + fun `RPC failure and malformed envelopes use REST`() = + runTest { + for (wire in listOf(null, "{}", "[]", """{"providers":null}""")) { + var restCalls = 0 + val repository = + ModelOptionsRepository( + connected = { true }, + request = { if (wire == null) error("Unsupported method") else Json.parseToJsonElement(wire) }, + rest = { + restCalls++ + fallback + }, + ) + assertEquals(fallback, repository.load()) + assertEquals(1, restCalls) + } + } + + @Test + fun `cancelled screen never falls back`() = + runTest { + val started = CompletableDeferred() + var restCalls = 0 + val repository = + ModelOptionsRepository( + connected = { true }, + request = { + started.complete(Unit) + awaitCancellation() + }, + rest = { + restCalls++ + fallback + }, + ) + val job = async { repository.load() } + started.await() + job.cancel() + job.join() + assertEquals(0, restCalls) + } +} From f0f63c22cfb3ea19cef5c1c8f52dd93e56bde701 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:09:58 +0300 Subject: [PATCH 2/2] test(#1164): isolate model integration tests from WS singleton --- .../test/java/com/m57/hermescontrol/E2eIntegrationTest.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 20d62d60..80fbaf41 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -50,6 +50,7 @@ import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.HermesApiService import com.m57.hermescontrol.data.remote.ServerEndpoint import com.m57.hermescontrol.data.ws.KanbanEventsClient +import com.m57.hermescontrol.data.ws.ModelOptionsRepository import com.m57.hermescontrol.ui.channels.ChannelsViewModel import com.m57.hermescontrol.ui.connect.ConnectViewModel import com.m57.hermescontrol.ui.cron.CronJobsViewModel @@ -1293,7 +1294,8 @@ class E2eIntegrationTest { ), ) - val viewModel = ModelViewModel() + // Issue #1164: keep the real WS singleton off this test's mocked IO dispatcher. + val viewModel = ModelViewModel(ModelOptionsRepository(connected = { false })) viewModel.loadAll() advanceUntilIdle() @@ -1352,7 +1354,8 @@ class E2eIntegrationTest { } // Default loadAll() must call getModelOptions with refresh = false - val viewModel = ModelViewModel() + // Issue #1164: keep the real WS singleton off this test's mocked IO dispatcher. + val viewModel = ModelViewModel(ModelOptionsRepository(connected = { false })) viewModel.loadAll() advanceUntilIdle()