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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String, Any>()
|
||||
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<String, Any>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ class ChatViewModelTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val mockEventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
|
||||
private val mockConnectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
|
||||
private val mockSelectedProfileFlow = MutableStateFlow<String?>(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 {
|
||||
|
||||
@@ -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<String>()) } 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<ChatViewModel, String> {
|
||||
val vm = ChatViewModel(app, false, fakeRepo)
|
||||
val vm = ChatViewModel(app, false, fakeRepo, ioDispatcher = testDispatcher)
|
||||
advanceUntilIdle()
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PinnedModel> = 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<List<PinnedModel>>().toMutableList()
|
||||
}
|
||||
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfile }
|
||||
every { AuthManager.setSelectedProfileId(any()) } answers {
|
||||
storedSelectedProfile = firstArg<String?>()
|
||||
}
|
||||
every { AuthManager.getToken() } answers { "tok-abc" }
|
||||
every { AuthManager.getProfileToken(any()) } answers { storedProfileToken }
|
||||
every { AuthManager.setProfileToken(any(), any()) } answers {
|
||||
storedProfileToken = secondArg<String?>()
|
||||
}
|
||||
|
||||
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_<profile>
|
||||
// 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"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user