feat(profiles): first-class profile switching — phase 0 baseline port (#820)

* feat(profiles): make profile switching actually scope the app (issue #781 follow-up)

* fix(profiles): inherit connection token when switching to same-dashboard profile (issue #781 follow-up)

* fix(chat): reset open session on profile switch + prefill login profile (desktop parity)

* fix(ws): inject profile into profile-scoped RPC params so chat follows the selected profile

* test(chat): de-poison SlashCommandDispatchRpcTest (drop static Dispatchers mock)

mockkStatic(Dispatchers::class) + a stubbed Dispatchers.IO bleeds JVM-wide and
breaks later classes in suite order (CI 2026-08-06: COMMAND_DISPATCH captures
cross-wired → ComparisonFailure on lower stack PRs). ChatViewModel gains an
injected ioDispatcher (38 sites); the slash test constructs the VM with the
test dispatcher so the IO stub is unnecessary.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-06 15:06:28 +03:00
1 parent e56d4c9bfa
commit 75ac0c4790
14 files changed
+579 -56

No files matched your search

@@ -73,6 +73,10 @@ object AuthManager {
private val _tokenFlow = MutableStateFlow<String?>(null)
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
/** Current profile scope (mirrors serverStore.selectedProfileId). */
private val _selectedProfileFlow = MutableStateFlow<String?>(null)
val selectedProfileFlow: StateFlow<String?> = _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
}
@@ -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",
)
/**
@@ -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) {
@@ -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<String> =
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
)
}
@@ -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<String, Any>,
): Map<String, Any> {
// 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)
}
}
}
@@ -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<String>()
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<String, Any>("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<String, Any>(
"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<String, Any>(
"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<String, Any>(
"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<String, Any>(
"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)
}
}
@@ -59,7 +59,7 @@ class ConnectViewModel(
baseUrl = selectedProfile?.resolveBaseUrl(savedBaseUrl) ?: savedBaseUrl,
profiles = profiles,
selectedProfile = selectedProfile,
profileName = selectedProfile?.name ?: "",
profileName = selectedProfile?.name ?: selectedId ?: "",
)
}
loadHealth()
@@ -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_<profileId>) 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()
}