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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user