diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt index 02d92bd1..72de4781 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt @@ -34,6 +34,7 @@ object ProfileScopeInterceptor : Interceptor { "/api/env", "/api/gateway", "/api/mcp", + "/api/memory", "/api/messaging/platforms", "/api/messaging/telegram/onboarding", "/api/messaging/whatsapp/onboarding", diff --git a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt index 316f9ae6..1ec57e4f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt @@ -8,6 +8,7 @@ import com.m57.hermescontrol.data.model.MemoryResponse 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.session.ProfileSwitchCoordinator import com.m57.hermescontrol.ui.common.ToastHost import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -37,9 +38,24 @@ class MemoryViewModel : private val _uiState = MutableStateFlow(MemoryUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun load() { + init { + viewModelScope.launch { + ProfileSwitchCoordinator.switched.collect { + load(silent = true) + } + } + viewModelScope.launch { + ProfileSwitchCoordinator.connectionSwitched.collect { + load(silent = true) + } + } + } + + fun load(silent: Boolean = false) { if (_uiState.value.isLoading) return - _uiState.update { it.copy(isLoading = true, errorMessage = null) } + if (!silent) { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + } viewModelScope.launch { val result = safeApiCall { ApiClient.hermesApi.getMemory() } when (result) { diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt index f4da2244..c97cee18 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt @@ -118,6 +118,19 @@ class ProfileScopeInterceptorTest { assertEquals("alpha", url.queryParameter("profile")) } + @Test + fun memoryEndpoints_areScoped() { + val client = clientFor("yasmin") + for (path in listOf("api/memory", "api/memory/learning-graph", "api/memory/providers/mem0/config")) { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + val req = Request.Builder().url(server.url(path)).build() + client.newCall(req).execute().close() + val url = server.takeRequest().requestUrl!! + assertEquals("yasmin", url.queryParameter("profile")) + assertEquals("/$path", url.encodedPath) + } + } + @Test fun lookalikePath_notScoped() { // Sourcery review (PR #540): `startsWith` must not match non-segment diff --git a/app/src/test/java/com/m57/hermescontrol/ui/memory/MemoryViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/memory/MemoryViewModelTest.kt index 6ccb1f64..cfb23409 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/memory/MemoryViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/memory/MemoryViewModelTest.kt @@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.model.MemoryResetResponse import com.m57.hermescontrol.data.model.MemoryResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -14,6 +15,7 @@ import io.mockk.mockkObject import io.mockk.unmockkAll import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -36,6 +38,8 @@ import retrofit2.Response class MemoryViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var mockApi: HermesApiService + private val mockSwitchFlow = MutableSharedFlow(extraBufferCapacity = 1) + private val mockConnSwitchFlow = MutableSharedFlow(extraBufferCapacity = 1) private val memoryResponse = MemoryResponse( @@ -56,6 +60,9 @@ class MemoryViewModelTest { fun setUp() { Dispatchers.setMain(testDispatcher) mockkObject(ApiClient) + mockkObject(ProfileSwitchCoordinator) + every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow + every { ProfileSwitchCoordinator.connectionSwitched } returns mockConnSwitchFlow mockApi = mockk(relaxed = true) every { ApiClient.hermesApi } returns mockApi coEvery { mockApi.getMemory() } returns Response.success(memoryResponse) @@ -79,6 +86,30 @@ class MemoryViewModelTest { assertEquals(1024L, state.memory?.builtin_files?.memory) } + @Test + fun `profile switch triggers silent reload`() { + val vm = MemoryViewModel() + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 0) { mockApi.getMemory() } + + mockSwitchFlow.tryEmit("yasmin") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { mockApi.getMemory() } + } + + @Test + fun `connection profile switch triggers silent reload`() { + val vm = MemoryViewModel() + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 0) { mockApi.getMemory() } + + mockConnSwitchFlow.tryEmit("server-remote") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { mockApi.getMemory() } + } + @Test fun `load also fetches learning graph for self-improvement section`() { val graph =