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

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