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)
}
}
}