diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index b533016b..2354dc9e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -73,6 +73,10 @@ object AuthManager { private val _tokenFlow = MutableStateFlow(null) val tokenFlow: StateFlow = _tokenFlow.asStateFlow() + /** Current profile scope (mirrors serverStore.selectedProfileId). */ + private val _selectedProfileFlow = MutableStateFlow(null) + val selectedProfileFlow: StateFlow = _selectedProfileFlow.asStateFlow() + /** * Initialise the encrypted preferences. * Call this once from Application.onCreate() or MainActivity.onCreate(). @@ -98,6 +102,7 @@ object AuthManager { appScope = scope val store = ServerStore(dataStore, scope) _serverStore = store + _selectedProfileFlow.value = store.getLatestState().selectedProfileId if (prefsDeferred == null) { prefsDeferred = @@ -332,6 +337,7 @@ object AuthManager { ActiveSessionHolder.clear() } serverStore.update { it.copy(selectedProfileId = id) } + _selectedProfileFlow.value = id synchronized(this) { tokenInitialized = false } 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 3d0199bf..5cb303fb 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 @@ -19,19 +19,20 @@ import okhttp3.Response * - If the URL already carries an explicit `profile=` query, leave it * untouched — explicit beats global. * - Only the endpoint families below honor `?profile=` on the backend; - * everything else (ops, pairing, cron, profiles themselves) is - * machine-global or self-scoped and must NOT be rewritten. + * everything else (ops, pairing, profiles themselves) is machine-global + * or self-scoped and must NOT be rewritten. Cron, sessions and plugins + * are profile-scoped on the backend (`/api/cron/jobs?profile=`, + * `/api/sessions?profile=`, plugin REST) and ARE rewritten here so + * switching profiles actually switches what the app shows. */ object ProfileScopeInterceptor : Interceptor { private val PROFILE_SCOPED_PREFIXES = listOf( - "/api/status", - "/api/gateway", "/api/analytics", - "/api/skills", - "/api/tools/toolsets", "/api/config", + "/api/cron", "/api/env", + "/api/gateway", "/api/mcp", "/api/messaging/platforms", "/api/messaging/telegram/onboarding", @@ -41,6 +42,11 @@ object ProfileScopeInterceptor : Interceptor { "/api/model/auxiliary", "/api/model/moa", "/api/model/options", + "/api/plugins", + "/api/sessions", + "/api/skills", + "/api/status", + "/api/tools/toolsets", ) /** diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 06293ada..47712310 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -580,7 +580,13 @@ object HermesWsClient { onSent: ((String) -> Unit)? = null, ): String { val id = requestId.incrementAndGet().toString() - val request = JsonRpcRequest(id = id, method = method, params = params.mapValues { it.value.toJsonElement() }) + val decoratedParams = WsProfileParams.decorate(method, params) + val request = + JsonRpcRequest( + id = id, + method = method, + params = decoratedParams.mapValues { it.value.toJsonElement() }, + ) val json = OkHttpProvider.json.encodeToString(request) if (BuildConfig.DEBUG) Log.d(TAG, "→ $json") synchronized(outboundLock) { 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 b809b2f5..4803ce3d 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 @@ -72,4 +72,54 @@ object WsMethods { /** Usage bars (token/cost breakdown the legacy credits block rendered). */ const val USAGE_BARS = "usage.bars" + + // ── Profile-scoped method set ──────────────────────────────────────── + // Methods that the TUI gateway resolves against the per-profile + // HERMES_HOME / state.db when `params.profile` is present. + // + // Two categories on the server: + // (a) @_profile_scoped (method_ctx.py) — binds HERMES_HOME via + // ContextVar for the handler's lifetime. + // (b) Manual params.get("profile") / _profile_db(params) — opens + // the named profile's state.db explicitly. + // + // This set is the union of both. Only methods the mobile app + // actually calls are listed (pet.* are included for completeness + // since they read per-profile config). The set is intentionally an + // explicit allowlist: unknown / new methods default to un-scoped, + // which is safe — the server ignores an unrecognised `profile` key. + + /** + * Methods whose server handler reads `params["profile"]` to resolve the + * correct per-profile data. Used by [WsProfileParams] to decide which + * RPCs get the active profile injected automatically. + */ + val PROFILE_SCOPED_METHODS: Set = + setOf( + // Category (b): manual params.get("profile") / _profile_db + // Evidence: methods_session.py lines 42, 164, 232, 317, 845 + SESSION_CREATE, // session.create + SESSION_LIST, // session.list + SESSION_RESUME, // session.resume + SESSION_DELETE, // session.delete + SESSION_STATUS, // session.status + // session.most_recent uses _profile_db(params) too, but + // mobile doesn't call it; documented for completeness. + // Category (a): @_profile_scoped decorator + // Evidence: methods_session.py @_profile_scoped at each @method + // Binds HERMES_HOME so config/skills/pets resolve to the + // focused profile. + "verification.status", // line 282 + "pet.info", // line 1253 + "pet.info.meta", // line 1279 + "pet.cells", // line 1302 + "pet.gallery", // line 1407 + "pet.select", // line 1490 + "pet.remove", // line 1517 + "pet.export", // line 1547 + "pet.rename", // line 1574 + "pet.thumb", // line 1612 + "pet.disable", // line 1647 + "pet.scale", // line 1661 + ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsProfileParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsProfileParams.kt new file mode 100644 index 00000000..0e6734aa --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsProfileParams.kt @@ -0,0 +1,46 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.local.AuthManager + +/** + * WS-layer analog of [com.m57.hermescontrol.data.remote.ProfileScopeInterceptor]. + * + * Injects `"profile"` into JSON-RPC `params` for profile-scoped methods so the + * TUI gateway resolves the correct `HERMES_HOME` / `state.db` for the selected + * profile. Without this, WS RPCs always hit the backend's **launch** profile + * (usually "default"), even after the user switches profiles in the app. + * + * Rules (mirror the REST interceptor contract): + * - If [AuthManager.getSelectedProfileId] is `null` → pass through unchanged + * (legacy single-profile behavior). + * - If the caller already supplied an explicit `"profile"` key in [params] → + * explicit wins; do **not** overwrite. + * - Only decorate methods in [WsMethods.PROFILE_SCOPED_METHODS]. Never touch + * global / ops RPCs (`setup.*`, `gateway.*`, `auth.*`, ticket flows, etc.). + */ +object WsProfileParams { + /** + * Return a copy of [params] with `"profile"` injected when the active + * profile is set and the [method] is profile-scoped. Returns [params] + * unchanged (same reference) when no injection is needed. + */ + fun decorate( + method: String, + params: Map, + ): Map { + // No active profile → legacy single-profile behavior. + val profile = AuthManager.getSelectedProfileId() ?: return params + + // Only scoped methods get the profile key. + if (method !in WsMethods.PROFILE_SCOPED_METHODS) return params + + // Explicit profile param from the caller wins. + if (params.containsKey("profile")) return params + + // Inject profile into a new map (params may be immutable / emptyMap). + return buildMap(params.size + 1) { + putAll(params) + put("profile", profile) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index c7938c90..3d6a402f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -346,6 +347,7 @@ class ChatViewModel( HermesDatabase.get(application).chatMessageDao(), ), searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default, + private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, ) : AndroidViewModel(application) { constructor(application: Application) : this(application, startCleanup = true) @@ -503,6 +505,18 @@ class ChatViewModel( } } } + // Desktop parity (requestFreshSession): when the selected profile + // changes, wipe the open conversation and reload the session list so + // the previous profile's context never leaks into the new profile's + // chat (the gateway re-homes on profile switch). + viewModelScope.launch { + AuthManager.selectedProfileFlow + .drop(1) + .collect { _ -> + resetSessionState(sessionId = null, title = "Hermes", isLoading = true) + loadSessions() + } + } if (wsClient.connectionStatus.value == ConnectionStatus.CONNECTED) { handleGatewayReady() } @@ -543,7 +557,7 @@ class ChatViewModel( _uiState.update { it.copy(isLoading = true) } } - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { wsClient.connect() } @@ -629,7 +643,7 @@ class ChatViewModel( for (effect in result.effects) { when (effect) { is ReducerEffect.PersistMessage -> { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { repo.persistMessage(effect.message, effect.sessionId) } } @@ -657,7 +671,7 @@ class ChatViewModel( // attachments (images inline, every other file tappable) // via the gateway /api/files/download endpoint. Works on a // remote phone too. - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { attachHostMedia(effect.sessionId, effect.messageId) } } @@ -1114,12 +1128,12 @@ class ChatViewModel( } // Persist under the original Desktop session ID. - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { repo.persistMessage(userMessage, storageSessionId) } // Upload attachments then submit prompt - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { val fileRefs = mutableListOf() for (attachment in attachments) { @@ -1273,7 +1287,7 @@ class ChatViewModel( // Persist — OUTSIDE update{} if (sessionId != null) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { repo.persistMessage(userMsg, sessionId) } } @@ -1328,7 +1342,7 @@ class ChatViewModel( val params = mutableMapOf("session_id" to sessionId) if (arg.isNotBlank()) params["name"] = arg val generation = sessionGeneration - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { wsClient.send( WsMethods.SESSION_BRANCH, params, @@ -1346,7 +1360,7 @@ class ChatViewModel( val parts = command.split(" ", limit = 2) val name = parts[0].lowercase().removePrefix("/") val arg = parts.getOrElse(1) { "" } - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { try { // Primary path: command.dispatch handles quick/plugin/bundle/ // skill commands + a few hardcoded ones. It returns a hard 4018 @@ -1429,7 +1443,7 @@ class ChatViewModel( } else { command.trim() } - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { wsClient.send( WsMethods.CONFIG_SET, mapOf("key" to "model", "value" to spec, "session_id" to sessionId), @@ -1447,7 +1461,7 @@ class ChatViewModel( if (text.isBlank()) return val sessionId = runtimeSessionId ?: return _uiState.update { it.copy(isAgentTyping = true) } - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { wsClient.sendMessage( sessionId, text, @@ -1463,7 +1477,7 @@ class ChatViewModel( // Persist — OUTSIDE update{} val sessionId = _uiState.value.currentSessionId if (sessionId != null) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { repo.persistMessage(msg, sessionId) } } @@ -1473,7 +1487,7 @@ class ChatViewModel( fun interruptSession() { val sessionId = runtimeSessionId ?: return - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { wsClient.send( WsMethods.SESSION_INTERRUPT, mapOf("session_id" to sessionId), @@ -1486,7 +1500,7 @@ class ChatViewModel( // A fresh create has no persisted row until the first prompt. sessionHasServerPresence = false val generation = resetSessionState(sessionId = null, title = "Hermes", isLoading = setLoading) - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { wsClient.send( WsMethods.SESSION_CREATE, params = mapOf("source" to "desktop"), @@ -1507,7 +1521,7 @@ class ChatViewModel( } fun loadSessions() { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { wsClient.send( WsMethods.SESSION_LIST, onSent = { id -> trackRequest(id, WsMethods.SESSION_LIST) }, @@ -1516,7 +1530,7 @@ class ChatViewModel( } private fun fetchCommandCatalog() { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { wsClient.send( WsMethods.COMMANDS_CATALOG, onSent = { id -> trackRequest(id, WsMethods.COMMANDS_CATALOG) }, @@ -1549,7 +1563,7 @@ class ChatViewModel( * bare count. */ private fun refreshMaxToolCallsPerTurn() { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { try { val response = ApiClient.hermesApi.getConfig() if (!response.isSuccessful) return@launch @@ -1599,7 +1613,7 @@ class ChatViewModel( /** Preload model options so the picker opens instantly (no spinner on /model). */ private fun preloadModelOptions() { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { val result = safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = false) @@ -1635,7 +1649,7 @@ class ChatViewModel( /** Re-fetch options (pull-to-refresh style) when the picker is already open. */ fun refreshModelOptions() { _uiState.update { it.copy(modelPickerLoading = true) } - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { val result = safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = true) @@ -1721,7 +1735,7 @@ class ChatViewModel( _uiState.update { it.copy(reasoningLevel = level) } val sessionId = runtimeSessionId ?: return if (level == null) return // null = model default, no need to send WS - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { wsClient.send( WsMethods.CONFIG_SET, mapOf( @@ -1763,7 +1777,7 @@ class ChatViewModel( sessionId: String, generation: Long, ): Job = - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { val cachedMessages = dedupeCachedMessages(repo.loadMessages(sessionId)) _uiState.update { state -> // Only paint if still showing this session AND no fresher server @@ -1799,7 +1813,7 @@ class ChatViewModel( val serverOffset = result.data.offset ?: offset val chatMessages = mapServerMessages(sessionId, result.data.messages.orEmpty(), serverOffset) loadedMessageOffset = serverOffset - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { repo.persistMessages(chatMessages, sessionId) } if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch @@ -1907,7 +1921,7 @@ class ChatViewModel( ) { val requestSequence = ++resumeRequestSequence activeResumeRequestSequence = requestSequence - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { wsClient.send( WsMethods.SESSION_RESUME, mapOf("session_id" to sessionId), @@ -2106,7 +2120,7 @@ class ChatViewModel( val returnedOffset = result.data.offset ?: newOffset val older = mapServerMessages(sessionId, result.data.messages.orEmpty(), returnedOffset) loadedMessageOffset = returnedOffset - withContext(Dispatchers.IO) { repo.persistMessages(older, sessionId) } + withContext(ioDispatcher) { repo.persistMessages(older, sessionId) } _uiState.update { current -> if (!isCurrentSessionRequest(sessionId, generation)) return@update current current.copy( @@ -2153,7 +2167,7 @@ class ChatViewModel( if (!isCurrentSessionRequest(sessionId, generation)) return@launch val incoming = mapServerMessages(sessionId, result.data.messages.orEmpty(), nextOffset) if (incoming.isEmpty()) return@launch - withContext(Dispatchers.IO) { repo.persistMessages(incoming, sessionId) } + withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) } _uiState.update { current -> if (!isCurrentSessionRequest(sessionId, generation)) return@update current // Issue #771: the sync merge was dropping the @@ -2251,7 +2265,7 @@ class ChatViewModel( fun fetchContextUsage() { val sessionId = _uiState.value.currentSessionId ?: return val profile = AuthManager.getSelectedProfileId() - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { // Denominator fallback: full context window (cheap, public, rarely // changes). The RPC's context_max below overrides it when present. val fullResult = @@ -2353,7 +2367,7 @@ class ChatViewModel( ?.messageCount if (known != null) return known val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getSessions(limit = 500, offset = 0, order = "recent") } } if (result is NetworkResult.Success) { @@ -2391,7 +2405,7 @@ class ChatViewModel( sessionId: String, offset: Int, limit: Int, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getSessionMessages( sessionId = sessionId, @@ -2648,7 +2662,7 @@ class ChatViewModel( } // GATEWAY, or LOCAL direct-open failed → fetch/copy then open. val path = gatewayPathFor(attachment) - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { when (val result = GatewayFileClient.fetch(path)) { is GatewayFileResult.Success -> { openBytes(ctx, result.file) @@ -2685,7 +2699,7 @@ class ChatViewModel( if (_uiState.value.savingAttachmentPath != null) return val path = gatewayPathFor(attachment) _uiState.update { it.copy(savingAttachmentPath = path) } - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { try { when (val result = GatewayFileClient.fetch(path)) { is GatewayFileResult.Success -> { @@ -2824,7 +2838,7 @@ class ChatViewModel( addSystemMessage("Clarify dismissed — no answer sent", persist = true) - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { val params = mutableMapOf( "session_id" to sessionId, @@ -2861,11 +2875,11 @@ class ChatViewModel( ) } - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { repo.persistMessage(userMessage, sessionId) } - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { val params = mutableMapOf( "session_id" to sessionId, @@ -2935,7 +2949,7 @@ class ChatViewModel( ) } - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { wsClient.send( method = WsMethods.APPROVAL_RESPOND, params = @@ -2997,7 +3011,7 @@ class ChatViewModel( _uiState.update { it.copy(sudoPrompt = null) } - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { val params = mutableMapOf( "session_id" to sessionId, @@ -3022,7 +3036,7 @@ class ChatViewModel( _uiState.update { it.copy(secretPrompt = null) } - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { val params = mutableMapOf( "session_id" to sessionId, @@ -3044,7 +3058,7 @@ class ChatViewModel( errorMessage = null, ) } - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { wsClient.rejectAllPending() wsClient.disconnect() } @@ -3059,7 +3073,7 @@ class ChatViewModel( password: String, onResult: (Boolean, String?) -> Unit, ) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { val endpoint = AuthManager.endpointForBuild() val jsonMediaType = "application/json; charset=utf-8".toMediaType() val jsonBody = @@ -3162,7 +3176,7 @@ class ChatViewModel( // Persist — OUTSIDE update{} if (persist && sessionId != null) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(ioDispatcher) { repo.persistMessage(msg, sessionId) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt index af8ae8b0..a08ab2e0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt @@ -59,7 +59,7 @@ class ConnectViewModel( baseUrl = selectedProfile?.resolveBaseUrl(savedBaseUrl) ?: savedBaseUrl, profiles = profiles, selectedProfile = selectedProfile, - profileName = selectedProfile?.name ?: "", + profileName = selectedProfile?.name ?: selectedId ?: "", ) } loadHealth() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt index ae0d78aa..b53d71f1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt @@ -19,6 +19,7 @@ import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest 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.HermesWsClient import com.m57.hermescontrol.ui.common.ToastHost import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -111,6 +112,24 @@ class ProfilesViewModel : } when (result) { is NetworkResult.Success -> { + // Persist the LOCAL selection so ProfileScopeInterceptor scopes + // every management REST call (?profile=) to the new profile, + // and re-home the WebSocket so the live gateway follows too + // (mirrors desktop's re-home-on-switch). + // + // Token inheritance: the app keys tokens per profile + // (token_) because connections can point at + // different dashboards, but profiles listed on THIS screen + // are all served by the SAME connected dashboard — so a + // profile created/selected in-app inherits the current + // connection's token instead of forcing a re-login. + val currentToken = AuthManager.getToken() + if (currentToken != null && AuthManager.getProfileToken(name) == null) { + AuthManager.setProfileToken(name, currentToken) + } + AuthManager.setSelectedProfileId(name) + HermesWsClient.disconnect() + HermesWsClient.connect() _uiState.update { it.copy(toastMessage = "Switched to profile $name") } loadProfiles() } 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 f87675b2..012650d2 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 @@ -79,14 +79,31 @@ class ProfileScopeInterceptorTest { @Test fun nonScopedEndpoint_untouched() { + // Pairing is machine-global (not profile-scoped on the backend). every { AuthManager.getSelectedProfileId() } returns "work" val client = OkHttpClient.Builder().addInterceptor(ProfileScopeInterceptor).build() server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) - val req = Request.Builder().url(server.url("api/cron/jobs")).build() + val req = Request.Builder().url(server.url("api/pairing")).build() client.newCall(req).execute().close() val url = server.takeRequest().requestUrl!! assertNull(url.queryParameter("profile")) - assertEquals("/api/cron/jobs", url.encodedPath) + assertEquals("/api/pairing", url.encodedPath) + } + + @Test + fun cronSessionsPluginsEndpoints_areScoped() { + // Issue #781 follow-up: cron, sessions and plugin REST are all + // profile-scoped on the backend — switching profiles must switch + // what these screens show, so the interceptor rewrites them too. + val client = clientFor("work") + for (path in listOf("api/cron/jobs", "api/sessions", "api/plugins/hermes-achievements/achievements")) { + 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("work", url.queryParameter("profile")) + assertEquals("/$path", url.encodedPath) + } } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt new file mode 100644 index 00000000..b13ad69e --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt @@ -0,0 +1,266 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.local.AuthManager +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Tests for [WsProfileParams] — the WS-layer analog of + * [com.m57.hermescontrol.data.remote.ProfileScopeInterceptor]. + * + * Covers the three injection rules: + * 1. Scoped method + active profile → `"profile"` injected. + * 2. Non-scoped method → params unchanged (same reference). + * 3. Explicit `"profile"` in params → preserved, never overwritten. + * 4. Null selected profile → params unchanged (same reference). + */ +class WsProfileParamsTest { + @Before + fun setUp() { + mockkObject(AuthManager) + } + + @After + fun tearDown() { + unmockkAll() + } + + // ── Rule 1: scoped method gets profile injected ───────────────────── + + @Test + fun `scoped method injects profile when active profile is set`() { + every { AuthManager.getSelectedProfileId() } returns "meow" + + val original = mapOf("session_id" to "abc123") + val result = WsProfileParams.decorate(WsMethods.SESSION_LIST, original) + + assertEquals("meow", result["profile"]) + assertEquals("abc123", result["session_id"]) + } + + @Test + fun `session_create gets profile injected`() { + every { AuthManager.getSelectedProfileId() } returns "work" + + val original = mapOf("cols" to 80) + val result = WsProfileParams.decorate(WsMethods.SESSION_CREATE, original) + + assertEquals("work", result["profile"]) + assertEquals(80, result["cols"]) + } + + @Test + fun `session_resume gets profile injected`() { + every { AuthManager.getSelectedProfileId() } returns "meow" + + val original = mapOf("session_id" to "s1") + val result = WsProfileParams.decorate(WsMethods.SESSION_RESUME, original) + + assertEquals("meow", result["profile"]) + assertEquals("s1", result["session_id"]) + } + + @Test + fun `session_delete gets profile injected`() { + every { AuthManager.getSelectedProfileId() } returns "work" + + val original = mapOf("session_id" to "s2") + val result = WsProfileParams.decorate(WsMethods.SESSION_DELETE, original) + + assertEquals("work", result["profile"]) + } + + @Test + fun `session_status gets profile injected`() { + every { AuthManager.getSelectedProfileId() } returns "meow" + + val original = mapOf("session_id" to "s3") + val result = WsProfileParams.decorate(WsMethods.SESSION_STATUS, original) + + assertEquals("meow", result["profile"]) + } + + @Test + fun `profile_scoped decorator method gets profile injected`() { + every { AuthManager.getSelectedProfileId() } returns "meow" + + val original = emptyMap() + val result = WsProfileParams.decorate("verification.status", original) + + assertEquals("meow", result["profile"]) + } + + @Test + fun `empty params map gets profile injected for scoped method`() { + every { AuthManager.getSelectedProfileId() } returns "meow" + + val result = WsProfileParams.decorate(WsMethods.SESSION_LIST, emptyMap()) + + assertEquals(1, result.size) + assertEquals("meow", result["profile"]) + } + + // ── Rule 2: non-scoped method left untouched ──────────────────────── + + @Test + fun `non-scoped method returns same params reference`() { + every { AuthManager.getSelectedProfileId() } returns "meow" + + val original = mapOf("session_id" to "s1", "text" to "hello") + val result = WsProfileParams.decorate(WsMethods.PROMPT_SUBMIT, original) + + assertSame(original, result) + assertNull(result["profile"]) + } + + @Test + fun `subscription method is not profile scoped`() { + every { AuthManager.getSelectedProfileId() } returns "meow" + + val original = mapOf("plan" to "pro") + val result = WsProfileParams.decorate(WsMethods.SUBSCRIPTION_STATE, original) + + assertSame(original, result) + } + + @Test + fun `clarify_respond is not profile scoped`() { + every { AuthManager.getSelectedProfileId() } returns "work" + + val original = mapOf("answer" to "yes") + val result = WsProfileParams.decorate(WsMethods.CLARIFY_RESPOND, original) + + assertSame(original, result) + } + + @Test + fun `image_attach_bytes is not profile scoped`() { + every { AuthManager.getSelectedProfileId() } returns "meow" + + val original = mapOf("data" to "base64data") + val result = WsProfileParams.decorate(WsMethods.IMAGE_ATTACH_BYTES, original) + + assertSame(original, result) + } + + @Test + fun `commands_catalog is not profile scoped`() { + every { AuthManager.getSelectedProfileId() } returns "meow" + + val original = emptyMap() + val result = WsProfileParams.decorate(WsMethods.COMMANDS_CATALOG, original) + + assertSame(original, result) + } + + @Test + fun `unknown method is not profile scoped`() { + every { AuthManager.getSelectedProfileId() } returns "meow" + + val original = mapOf("foo" to "bar") + val result = WsProfileParams.decorate("setup.wizard", original) + + assertSame(original, result) + } + + // ── Rule 3: explicit profile param wins ───────────────────────────── + + @Test + fun `explicit profile in params is never overwritten`() { + every { AuthManager.getSelectedProfileId() } returns "meow" + + val original = mapOf("session_id" to "s1", "profile" to "custom") + val result = WsProfileParams.decorate(WsMethods.SESSION_LIST, original) + + assertSame(original, result) + assertEquals("custom", result["profile"]) + } + + @Test + fun `explicit empty-string profile is preserved`() { + every { AuthManager.getSelectedProfileId() } returns "meow" + + // Caller explicitly passes profile="" — the containsKey check + // preserves this so the server sees the explicit value. + val original = mapOf("session_id" to "s1", "profile" to "") + val result = WsProfileParams.decorate(WsMethods.SESSION_LIST, original) + + assertSame(original, result) + assertEquals("", result["profile"]) + } + + // ── Rule 4: null selected profile → pass-through ──────────────────── + + @Test + fun `null selected profile returns same params reference for scoped method`() { + every { AuthManager.getSelectedProfileId() } returns null + + val original = mapOf("session_id" to "s1") + val result = WsProfileParams.decorate(WsMethods.SESSION_LIST, original) + + assertSame(original, result) + assertNull(result["profile"]) + } + + @Test + fun `null selected profile returns same params for non-scoped method`() { + every { AuthManager.getSelectedProfileId() } returns null + + val original = mapOf("text" to "hello") + val result = WsProfileParams.decorate(WsMethods.PROMPT_SUBMIT, original) + + assertSame(original, result) + } + + // ── Coverage: all scoped methods in the set ───────────────────────── + + @Test + fun `all PROFILE_SCOPED_METHODS entries get profile injected`() { + every { AuthManager.getSelectedProfileId() } returns "testprofile" + + for (method in WsMethods.PROFILE_SCOPED_METHODS) { + val result = WsProfileParams.decorate(method, emptyMap()) + assertEquals( + "Expected profile injection for method $method", + "testprofile", + result["profile"], + ) + } + } + + @Test + fun `PROFILE_SCOPED_METHODS set is non-empty and contains expected core methods`() { + assertTrue(WsMethods.PROFILE_SCOPED_METHODS.isNotEmpty()) + assertTrue(WsMethods.SESSION_LIST in WsMethods.PROFILE_SCOPED_METHODS) + assertTrue(WsMethods.SESSION_CREATE in WsMethods.PROFILE_SCOPED_METHODS) + assertTrue(WsMethods.SESSION_RESUME in WsMethods.PROFILE_SCOPED_METHODS) + assertTrue(WsMethods.SESSION_DELETE in WsMethods.PROFILE_SCOPED_METHODS) + assertTrue(WsMethods.SESSION_STATUS in WsMethods.PROFILE_SCOPED_METHODS) + assertTrue("verification.status" in WsMethods.PROFILE_SCOPED_METHODS) + } + + // ── Interaction: sendMessage() flows through send() ───────────────── + // sendMessage() calls send(PROMPT_SUBMIT, ...) — PROMPT_SUBMIT is NOT + // in the scoped set, so profile is correctly NOT injected. The server + // resolves prompt.submit against the already-resumed session, which + // carries its own profile_home. + + @Test + fun `prompt_submit is not in scoped set confirming sendMessage path is correct`() { + every { AuthManager.getSelectedProfileId() } returns "meow" + + val params = mapOf("session_id" to "s1", "text" to "hello world") + val result = WsProfileParams.decorate(WsMethods.PROMPT_SUBMIT, params) + + // prompt.submit is session-bound, not profile-scoped. + assertSame(params, result) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index fd6cda0f..ff988526 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -63,6 +63,7 @@ class ChatViewModelTest { private val testDispatcher = StandardTestDispatcher() private val mockEventsFlow = MutableSharedFlow(extraBufferCapacity = 64) private val mockConnectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED) + private val mockSelectedProfileFlow = MutableStateFlow(null) private lateinit var app: Application private lateinit var fakeRepo: FakeChatPersistenceRepository @@ -110,6 +111,7 @@ class ChatViewModelTest { every { AuthManager.getToken() } returns "test-token" every { AuthManager.getBaseUrl() } returns "http://test.local/" every { AuthManager.getSelectedProfileId() } returns null + every { AuthManager.selectedProfileFlow } returns mockSelectedProfileFlow every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isAutoReconnect() } returns false @@ -241,6 +243,24 @@ class ChatViewModelTest { verify(atLeast = 1) { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) } } + @Test + fun profileSwitch_resetsOpenSessionAndReloadsSessionList() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + assertEquals(sessionId, viewModel.uiState.value.currentSessionId) + + // Selected profile changes (switch in ProfilesScreen) → chat must + // wipe the stale conversation and reload the session list + // (desktop's requestFreshSession parity — the gateway re-homes + // on profile switch and the old session id is no longer valid). + mockSelectedProfileFlow.value = "meow" + advanceUntilIdle() + + assertNull(viewModel.uiState.value.currentSessionId) + assertEquals("Hermes", viewModel.uiState.value.chatTitle) + verify(atLeast = 1) { HermesWsClient.send(WsMethods.SESSION_LIST, any(), any()) } + } + @Test fun testSlashCommand_fork_sendsSessionBranch() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index 89eedd67..6d922f6a 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -55,7 +55,6 @@ class SlashCommandDispatchRpcTest { @Before fun setUp() { Dispatchers.setMain(testDispatcher) - val testMainDispatcher = Dispatchers.Main reqCount = 0 mockkStatic(android.util.Log::class) @@ -64,10 +63,11 @@ class SlashCommandDispatchRpcTest { every { android.util.Log.w(any(), any()) } returns 0 every { android.util.Log.e(any(), any(), any()) } returns 0 - mockkStatic(Dispatchers::class) - every { Dispatchers.IO } returns testDispatcher - every { Dispatchers.Main } returns testMainDispatcher - + // NOTE: no mockkStatic(Dispatchers) here — a static Dispatchers mock + // bleeds JVM-wide and breaks later classes (CI 2026-08-06: this + // class's own static IO mock cross-wired COMMAND_DISPATCH captures in + // suite order). setMain alone is safe — the slash-dispatch send path + // is synchronous (no IO hop). mockkObject(AuthManager) mockkObject(HermesWsClient) mockkObject(ApiClient) @@ -110,7 +110,7 @@ class SlashCommandDispatchRpcTest { } private suspend fun TestScope.createViewModelWithSession(): Pair { - val vm = ChatViewModel(app, false, fakeRepo) + val vm = ChatViewModel(app, false, fakeRepo, ioDispatcher = testDispatcher) advanceUntilIdle() mockConnectionStatus.value = ConnectionStatus.CONNECTED mockEventsFlow.emit(WsEvent.GatewayReady(null)) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt index 543008ac..4695c035 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt @@ -392,7 +392,7 @@ class ConnectViewModelTest { } @Test - fun testLoadSavedValues_withStaleSelectedId_fallsBackToDefaults() { + fun testLoadSavedValues_withStaleSelectedId_prefillsSelectedId() { val profile = ConnectionProfile( id = "prof-1", @@ -407,7 +407,10 @@ class ConnectViewModelTest { val state = viewModel.uiState.value assertNull(state.selectedProfile) - assertEquals("", state.profileName) + // Profile name prefills the selected id so a re-login after switching + // to a profile created in-app (no saved connection yet) stays on that + // profile instead of silently landing back on default. + assertEquals("nonexistent-id", state.profileName) assertEquals("https://127.0.0.1:9119/", state.baseUrl) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt index 6ee5f3fc..7c784fae 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt @@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.model.RenameProfileRequest import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.ws.HermesWsClient import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -20,6 +21,7 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.unmockkAll +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -50,6 +52,8 @@ class ProfilesViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var mockApi: HermesApiService private var storedPinnedModels: MutableList = mutableListOf() + private var storedSelectedProfile: String? = null + private var storedProfileToken: String? = null private fun stubProfilesLoad() { coEvery { mockApi.getProfiles() } returns @@ -68,10 +72,25 @@ class ProfilesViewModelTest { mockkObject(AuthManager) storedPinnedModels = mutableListOf() + storedSelectedProfile = null + storedProfileToken = null every { AuthManager.getPinnedModels() } answers { storedPinnedModels.toList() } every { AuthManager.savePinnedModels(any()) } answers { storedPinnedModels = firstArg>().toMutableList() } + every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfile } + every { AuthManager.setSelectedProfileId(any()) } answers { + storedSelectedProfile = firstArg() + } + every { AuthManager.getToken() } answers { "tok-abc" } + every { AuthManager.getProfileToken(any()) } answers { storedProfileToken } + every { AuthManager.setProfileToken(any(), any()) } answers { + storedProfileToken = secondArg() + } + + mockkObject(HermesWsClient) + every { HermesWsClient.disconnect() } returns Unit + every { HermesWsClient.connect() } returns Unit mockkObject(ApiClient) mockApi = mockk(relaxed = true) @@ -262,4 +281,55 @@ class ProfilesViewModelTest { assertTrue(vm.uiState.value.modelPickerPinned.isEmpty()) assertTrue(storedPinnedModels.isEmpty()) } + + @Test + fun `selectActiveProfile success persists selection and rehomes websocket`() { + coEvery { mockApi.setActiveProfile(any()) } returns Response.success(Unit) + + val vm = createViewModel() + vm.selectActiveProfile("work") + testDispatcher.scheduler.advanceUntilIdle() + + // Local selection persisted → ProfileScopeInterceptor now scopes all + // management REST calls (?profile=work) to the new profile. + assertEquals("work", storedSelectedProfile) + // Token inherited from the current connection so switching to a + // same-dashboard profile doesn't force a re-login (token_ + // is keyed per profile and a freshly-created profile has none). + assertEquals("tok-abc", storedProfileToken) + // WebSocket re-homed so the live gateway follows the new profile. + verify { HermesWsClient.disconnect() } + verify { HermesWsClient.connect() } + assertTrue(vm.uiState.value.toastMessage!!.contains("Switched to profile work")) + coVerify { mockApi.getProfiles() } + } + + @Test + fun `selectActiveProfile keeps existing token for the target profile`() { + storedProfileToken = "tok-meow" + coEvery { mockApi.setActiveProfile(any()) } returns Response.success(Unit) + + val vm = createViewModel() + vm.selectActiveProfile("meow") + testDispatcher.scheduler.advanceUntilIdle() + + // A profile that already has its own token (e.g. connected elsewhere) + // must NOT be clobbered by inheritance. + assertEquals("tok-meow", storedProfileToken) + assertEquals("meow", storedSelectedProfile) + } + + @Test + fun `selectActiveProfile failure rolls back and keeps local selection`() { + coEvery { mockApi.setActiveProfile(any()) } returns errorResponse(500) + + val vm = createViewModel() + vm.selectActiveProfile("work") + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(storedSelectedProfile) + assertNull(storedProfileToken) + assertNull(vm.uiState.value.activeProfileName) + assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to switch profile")) + } }