diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 7f863c92..42ec81c3 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -80,6 +80,8 @@ import kotlinx.serialization.Serializable @Serializable data object FilesScreen : NavKey +@Serializable data object AccountConnectorsScreen : NavKey + // ── Settings drill-down sub-pages ────────────────────────────────────── @Serializable data object SettingsConnection : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt index f812b30a..eadc0974 100644 --- a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt @@ -39,6 +39,7 @@ import com.m57.hermescontrol.ui.bots.BotsScreen as BotsScreenContent import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent +import com.m57.hermescontrol.ui.connectors.AccountConnectorsScreen as AccountConnectorsScreenContent import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent import com.m57.hermescontrol.ui.files.FilesScreen as FilesScreenContent import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent @@ -236,6 +237,12 @@ object ScreenRegistry { Icons.Filled.EmojiEvents, DrawerSection.INSPECT, ) { sessionId, openDrawer -> AchievementsScreenContent(onOpenDrawer = openDrawer) }, + ScreenDefinition( + AccountConnectorsScreen, + R.string.screen_account_connectors, + Icons.Filled.Extension, + DrawerSection.CONFIGURE, + ) { _, openDrawer -> AccountConnectorsScreenContent(onOpenDrawer = openDrawer) }, ScreenDefinition( SettingsScreen, R.string.screen_settings, diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt index 31a9038b..e5a9167c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt @@ -13,6 +13,7 @@ data class ConnectionOperationSnapshot( val settled: Boolean, val settledBy: String?, val targets: List, + val accountOwned: Boolean = false, ) data class ConnectionOperationTarget( diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt index 13d16db0..092af4aa 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt @@ -231,3 +231,70 @@ sealed interface ConnectorConnectResult { fun errorOrNull(): ConnectorError? = (this as? Error)?.error } + +/** Account-level connector records exposed outside the chat session lifecycle. */ +data class ConnectorAccount( + val connectionId: String, + val connector: String, + val status: String, + val statusReason: String?, + val label: String, + val alias: String?, + val active: Boolean, + val createdAt: String, + val updatedAt: String, +) + +data class ConnectorCatalogEntry( + val slug: String, + val name: String, + val description: String, + val category: String, +) + +data class ConnectorTool( + val slug: String, + val name: String, + val description: String, + val facet: String, + val deprecated: Boolean, + val hints: List = emptyList(), +) + +data class ConnectorPolicy( + val revision: String, + val mode: String, + val connectors: List, + val disabledConnectors: List, + val tools: Map>, + val enabledTags: List = emptyList(), + val disabledTags: List = emptyList(), + val member: ConnectorPolicy? = null, + val inherited: List = emptyList(), +) { + fun toolEnabled( + slug: String, + tool: ConnectorTool, + ): Boolean = + connectorEnabled(slug) && tool.slug !in tools[slug].orEmpty() && + disabledTags.none { it in tool.hints } && (enabledTags.isEmpty() || enabledTags.any { it in tool.hints }) + + fun connectorEnabled(slug: String): Boolean = + when (mode) { + "unrestricted" -> true + "deny-all" -> false + "allow" -> slug in connectors + "deny" -> slug !in disabledConnectors + else -> false + } +} + +sealed interface AccountConnectorResult { + data class Success( + val value: T, + ) : AccountConnectorResult + + data class Failure( + val error: ConnectorError, + ) : AccountConnectorResult +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt new file mode 100644 index 00000000..9a5fee1d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt @@ -0,0 +1,341 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.AccountConnectorResult +import com.m57.hermescontrol.data.model.ConnectorAccount +import com.m57.hermescontrol.data.model.ConnectorCatalogEntry +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorPolicy +import com.m57.hermescontrol.data.model.ConnectorTool +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +interface AccountConnectorRepository { + suspend fun catalog(): AccountConnectorResult> + + suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult + + suspend fun connect( + slugs: List, + reconnect: Boolean = false, + ): AccountConnectorResult + + suspend fun accounts(): AccountConnectorResult> + + suspend fun remove(connectionId: String): AccountConnectorResult + + suspend fun tools( + slug: String, + refresh: Boolean = false, + ): AccountConnectorResult> + + suspend fun policy(): AccountConnectorResult + + suspend fun setConnectorEnabled( + slug: String, + enabled: Boolean, + expectedRevision: String, + ): AccountConnectorResult + + suspend fun setDisabledTools( + slug: String, + disabled: List, + expectedRevision: String, + ): AccountConnectorResult + + suspend fun operationRequest( + method: String, + params: Map, + ): Any? + + suspend fun operationStatus( + opId: String, + ): AccountConnectorResult + + companion object : AccountConnectorRepository by HermesAccountConnectorRepository() +} + +class HermesAccountConnectorRepository( + private val rpc: suspend (String, Map) -> Any? = { method, params -> + val deferred = HermesWsClient.request(method, params) + try { + deferred.await() + } catch ( + e: CancellationException, + ) { + deferred.cancel(e) + throw e + } finally { + if (!deferred.isCompleted) deferred.cancel() + } + }, +) : AccountConnectorRepository { + private suspend fun request( + method: String, + params: Map = emptyMap(), + ) = rpc(method, params) + + private suspend fun guarded(block: suspend () -> T): AccountConnectorResult = + try { + AccountConnectorResult.Success(block()) + } catch (e: CancellationException) { + throw e + } catch (e: HermesWsClient.HermesRpcException) { + AccountConnectorResult.Failure(ConnectorParser.mapRpcError(e.code, e.message, e.data)) + } catch (_: IllegalArgumentException) { + AccountConnectorResult.Failure(ConnectorError.InvalidResponse()) + } catch (_: IllegalStateException) { + AccountConnectorResult.Failure(ConnectorError.InvalidResponse()) + } catch ( + _: Exception, + ) { + AccountConnectorResult.Failure(ConnectorError.NetworkError("Failed to communicate with gateway.")) + } + + private fun accountParams(params: Map = emptyMap()): Map = + buildMap { + putAll(params) + AuthManager.activeProfileId.value?.let { put("profile", it) } + } + + private fun obj(value: Any?): JsonObject = ConnectorParser.toJsonElement(value).jsonObject + + private fun JsonObject.str(k: String) = this[k]?.jsonPrimitive?.contentOrNull.orEmpty() + + private fun JsonObject.bool(k: String) = this[k]?.jsonPrimitive?.contentOrNull == "true" + + private fun JsonObject.array(k: String): JsonArray = this[k]?.jsonArray ?: JsonArray(emptyList()) + + private fun JsonObject.stringList(k: String) = array(k).mapNotNull { it.jsonPrimitive.contentOrNull } + + override suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult = + try { + ConnectorParser.parseListResult( + rpc( + WsMethods.CONNECTORS_LIST, + accountParams( + mapOf("owner" to mapOf("type" to "account")), + ), + ), + ) + } catch ( + e: CancellationException, + ) { + throw e + } catch ( + e: HermesWsClient.HermesRpcException, + ) { + com.m57.hermescontrol.data.model.ConnectorListResult.Error( + ConnectorParser.mapRpcError(e.code, e.message, e.data), + ) + } catch ( + _: Exception, + ) { + com.m57.hermescontrol.data.model.ConnectorListResult + .Error(ConnectorError.NetworkError()) + } + + override suspend fun connect( + slugs: List, + reconnect: Boolean, + ) = guarded { + require(slugs.isNotEmpty() && slugs.all(ConnectorRepository::isValidSlug)) + parseOperation( + request( + WsMethods.CONNECTORS_CONNECT, + accountParams( + mapOf( + "owner" to mapOf("type" to "account"), + "connectors" to slugs, + "reconnect" to reconnect, + ), + ), + ), + ) + } + + override suspend fun operationRequest( + method: String, + params: Map, + ): Any? = request(method, accountParams(params + ("owner" to mapOf("type" to "account")))) + + override suspend fun operationStatus(opId: String) = + guarded { + parseOperation(operationRequest(WsMethods.CONNECTORS_OPERATION_STATUS, mapOf("op_id" to opId))) + } + + @Suppress("UNCHECKED_CAST") + private fun parseOperation(raw: Any?): com.m57.hermescontrol.data.model.ConnectionOperationSnapshot = + checkNotNull( + ConnectionOperationParser.parse( + (ConnectorParser.toJsonElement(raw).toAny() as Map) + + ("owner" to mapOf("type" to "account")), + ), + ) + + override suspend fun catalog() = + guarded { + obj(request(WsMethods.CONNECTORS_CATALOG, accountParams()))["connectors"]!!.jsonArray.map { + val row = it.jsonObject + ConnectorCatalogEntry(row.str("slug"), row.str("name"), row.str("description"), row.str("category")) + } + } + + override suspend fun accounts() = + guarded { + obj(request(WsMethods.CONNECTORS_ACCOUNTS, accountParams()))["accounts"]!!.jsonArray.map { e -> + val r = e.jsonObject + ConnectorAccount( + r.str("connection_id"), + r.str("connector"), + r.str("status"), + r["status_reason"]?.jsonPrimitive?.contentOrNull, + r.str("label"), + r["alias"]?.jsonPrimitive?.contentOrNull, + r.bool("active"), + r.str("created_at"), + r.str("updated_at"), + ) + } + } + + override suspend fun remove(connectionId: String) = + guarded { + require(connectionId.isNotBlank()) + val response = + obj( + request( + WsMethods.CONNECTORS_ACCOUNTS_REMOVE, + accountParams( + mapOf("connection_id" to connectionId), + ), + ), + ) + check(response.str("status") == "removed" && response.str("connection_id") == connectionId) + Unit + } + + override suspend fun tools( + slug: String, + refresh: Boolean, + ) = guarded { + require(ConnectorRepository.isValidSlug(slug)) + val params = + buildMap { + put("slug", slug) + if (refresh) put("refresh", true) + } + obj(request(WsMethods.CONNECTORS_TOOLS, accountParams(params)))["tools"]!!.jsonArray.map { e -> + val r = e.jsonObject + ConnectorTool( + r.str("slug"), + r.str("name"), + r.str("description"), + r.str("facet"), + r.bool("deprecated"), + r.stringList("hints"), + ) + } + } + + override suspend fun policy() = + guarded { + val root = obj(request(WsMethods.CONNECTORS_POLICY_GET, accountParams())) + val layers = root.array("layers").map { it.jsonObject } + val member = layers.firstOrNull { it.str("kind") == "member" } + parsePolicy(root["effective"]!!.jsonObject).copy( + member = member?.let { parsePolicy(it["body"]!!.jsonObject, it.str("revision")) }, + inherited = + layers.filter { it.str("kind") != "member" }.map { + parsePolicy(it["body"]!!.jsonObject, it.str("revision")) + }, + ) + } + + override suspend fun setConnectorEnabled( + slug: String, + enabled: Boolean, + expectedRevision: String, + ) = setPolicy( + expectedRevision, + mapOf( + "type" to "connector", + "connector" to slug, + "enabled" to enabled, + ), + ) + + override suspend fun setDisabledTools( + slug: String, + disabled: List, + expectedRevision: String, + ) = setPolicy( + expectedRevision, + mapOf( + "type" to "tools", + "connector" to slug, + "disabled_tools" to disabled, + ), + ) + + private suspend fun setPolicy( + expectedRevision: String, + change: Map, + ) = guarded { + val scope = AuthManager.currentDataScope() + require(ConnectorRepository.isValidSlug(change["connector"] as? String)) + require(expectedRevision.isNotBlank()) + check(scope == AuthManager.currentDataScope()) { "Account scope changed" } + val result = + obj( + request( + WsMethods.CONNECTORS_POLICY_SET, + accountParams( + mapOf( + "change" to change, + "expected_revision" to expectedRevision, + ), + ), + ), + ) + check(scope == AuthManager.currentDataScope()) { "Account scope changed" } + when (val refreshed = policy()) { + is AccountConnectorResult.Success -> refreshed.value + is AccountConnectorResult.Failure -> parsePolicy(result["effective"]!!.jsonObject) + } + } + + private fun parsePolicy( + r: JsonObject, + revision: String = r.str("revision"), + ) = ConnectorPolicy( + revision, + r.str("mode"), + r.stringList("connectors"), + r.stringList("disabled_connectors"), + (r["tools"] as? JsonObject)?.mapValues { (_, v) -> + (v as? JsonArray)?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + } + ?: emptyMap(), + enabledTags = + (r["tags"] as? JsonObject) + ?.let { + (it["enable"] as? JsonArray)?.mapNotNull { tag -> + tag.jsonPrimitive.contentOrNull + } + }.orEmpty(), + disabledTags = + (r["tags"] as? JsonObject) + ?.let { + (it["disable"] as? JsonArray)?.mapNotNull { tag -> + tag.jsonPrimitive.contentOrNull + } + }.orEmpty(), + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt index 088c1e4e..04e490b5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt @@ -39,6 +39,7 @@ object ConnectionOperationParser { settled = payload["settled"] as? Boolean ?: false, settledBy = payload["settled_by"] as? String, targets = targets, + accountOwned = (payload["owner"] as? Map<*, *>)?.get("type") == "account", ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index cdbf2e51..9dbe8cb5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -104,12 +104,19 @@ object WsMethods { const val CONNECTION_RESPOND = "connection.respond" const val CONNECTORS_OPERATION_WAKE = "connectors.operation.wake" + const val CONNECTORS_OPERATION_STATUS = "connectors.operation.status" /** List available third-party connectors and authorization state for a session. */ const val CONNECTORS_LIST = "connectors.list" /** Initiate or restart authorization flow for one or more connectors. */ const val CONNECTORS_CONNECT = "connectors.connect" + const val CONNECTORS_CATALOG = "connectors.catalog" + const val CONNECTORS_ACCOUNTS = "connectors.accounts" + const val CONNECTORS_ACCOUNTS_REMOVE = "connectors.accounts.remove" + const val CONNECTORS_TOOLS = "connectors.tools" + const val CONNECTORS_POLICY_GET = "connectors.policy.get" + const val CONNECTORS_POLICY_SET = "connectors.policy.set" // ── Plugins ─────────────────────────────────────────────────────────── const val PLUGINS_MANAGE = "plugins.manage" @@ -165,6 +172,12 @@ object WsMethods { SESSION_CREATE, // session.create SESSION_LIST, // session.list MODEL_OPTIONS, // model.options: @_profile_scoped + CONNECTORS_CATALOG, + CONNECTORS_ACCOUNTS, + CONNECTORS_ACCOUNTS_REMOVE, + CONNECTORS_TOOLS, + CONNECTORS_POLICY_GET, + CONNECTORS_POLICY_SET, SESSION_RESUME, // session.resume SESSION_DELETE, // session.delete SESSION_STATUS, // session.status diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt index 0166a881..14ab2ba6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt @@ -60,7 +60,10 @@ internal class ConnectionResumeCheckpoint( /** Session-scoped authoritative reducer for backend-owned connector operations. */ class ChatConnectionOperationDelegate( private val requester: ConnectionOperationRequester, + private val accountOwned: Boolean, ) { + constructor(requester: ConnectionOperationRequester) : this(requester, false) + private val _state = MutableStateFlow(ConnectionOperationUiState()) val state: StateFlow = _state.asStateFlow() private var sessionId: String? = null @@ -170,7 +173,7 @@ class ChatConnectionOperationDelegate( method = WsMethods.CONNECTION_RESPOND, params = mapOf( - "owner" to ConnectorRepository.sessionOwner(requireSessionId()), + "owner" to ownerParams(), "op_id" to snapshot.opId, "result" to mapOf("targets" to listOf(answer)), ), @@ -183,7 +186,7 @@ class ChatConnectionOperationDelegate( method = WsMethods.CONNECTION_RESPOND, params = mapOf( - "owner" to ConnectorRepository.sessionOwner(requireSessionId()), + "owner" to ownerParams(), "op_id" to snapshot.opId, "result" to mapOf("settled_by" to "continue"), ), @@ -196,7 +199,7 @@ class ChatConnectionOperationDelegate( val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return dispatch( method = WsMethods.CONNECTORS_OPERATION_WAKE, - params = mapOf("owner" to ConnectorRepository.sessionOwner(requireSessionId()), "op_id" to snapshot.opId), + params = mapOf("owner" to ownerParams(), "op_id" to snapshot.opId), clearOnSuccess = true, unknownOperationSettles = true, ) @@ -237,7 +240,7 @@ class ChatConnectionOperationDelegate( @Synchronized private fun begin(action: ConnectionPendingAction): ConnectionOperationSnapshot? { val current = _state.value.operation ?: return null - if (sessionId.isNullOrBlank() || + if ((!accountOwned && sessionId.isNullOrBlank()) || _state.value.pendingAction != null || current.opId != action.opId || current.seq != action.observedSeq @@ -259,9 +262,15 @@ class ChatConnectionOperationDelegate( } private fun matchesSession(snapshot: ConnectionOperationSnapshot): Boolean = - !sessionId.isNullOrBlank() && snapshot.sessionId == sessionId + if (accountOwned) { + snapshot.accountOwned + } else { + !snapshot.accountOwned && !sessionId.isNullOrBlank() && + snapshot.sessionId == sessionId + } - private fun requireSessionId(): String = checkNotNull(sessionId?.takeIf { it.isNotBlank() }) + private fun ownerParams(): Map = + if (accountOwned) mapOf("type" to "account") else ConnectorRepository.sessionOwner(checkNotNull(sessionId)) private fun currentOp(): String = _state.value.operation diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt index 3eb0c605..8e09d120 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -179,7 +179,7 @@ internal fun ToolBubble( .padding(horizontal = 10.dp, vertical = 6.dp), ) { // ── Header row: icon + tool name ── - HeaderRow(message, config, contentColor, statusColors) + HeaderRow(message, config, contentColor, statusColors, view?.serverDisplayName) // ── Tool progress preview (tool.progress) ── if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) { @@ -528,6 +528,7 @@ private fun HeaderRow( config: ToolDisplayConfig, contentColor: Color, statusColors: HermesStatusColors, + displayName: String? = null, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -562,7 +563,7 @@ private fun HeaderRow( } Text( - text = message.toolName ?: stringResource(R.string.chat_tool_fallback), + text = displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback), style = MaterialTheme.typography.labelMedium.copy( color = contentColor, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt index 24638217..7e52589a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.ui.chat.tool.ToolView import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -42,7 +43,15 @@ fun parseToolOutput( // Extract args sub-object if present (new tool.complete format) and // the result sub-object, falling back to the top-level payload (old // format where result fields sat at the root). - val args = element["args"] as? JsonObject + val rawArgs = element["args"] as? JsonObject + // Gateway labels are envelope metadata; never interpret a real args.labels value as display text. + val labels = element["labels"] as? JsonArray + val args = + if (labels != null) { + JsonObject(rawArgs.orEmpty() + ("hermes_tool_labels" to labels)) + } else { + rawArgs + } val result = element["result"] as? JsonObject ?: element ToolViewBuilder.build( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt index aed5a017..430e777b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt @@ -64,4 +64,5 @@ data class ToolView( val searchQuery: String? = null, /** Detected error text (destructive styling in the view). */ val error: String? = null, + val serverDisplayName: String? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index 8666af00..f1807d13 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -1,7 +1,10 @@ package com.m57.hermescontrol.ui.chat.tool +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull /** * The tool-display engine's orchestrator — a port of the desktop app's @@ -48,12 +51,14 @@ object ToolViewBuilder { ToolStatusResolver.errorText(call, isError) } + val serverTitle = serverLabelTitle(call.args) val title = - when { - running -> renderer.pendingTitle(call) - error.isNotEmpty() -> renderer.errorTitle(call) - else -> renderer.doneTitle(call) - } ?: ToolJson.titleForTool(toolName) + serverTitle + ?: when { + running -> renderer.pendingTitle(call) + error.isNotEmpty() -> renderer.errorTitle(call) + else -> renderer.doneTitle(call) + } ?: ToolJson.titleForTool(toolName) val subtitle = if (error.isNotEmpty()) { @@ -103,6 +108,7 @@ object ToolViewBuilder { return ToolView( status = status, title = title, + serverDisplayName = serverTitle, subtitle = subtitle, detail = clampedDetail, detailLabel = extras.detailLabel, @@ -125,6 +131,20 @@ object ToolViewBuilder { ) } + /** #1288: server display metadata, not the real tool's `labels` argument. */ + internal fun serverLabelTitle(args: JsonObject?): String? = + (args?.get("hermes_tool_labels") as? JsonArray) + ?.mapNotNull { entry -> + val row = entry as? JsonObject ?: return@mapNotNull null + val text = + (row["text"] as? JsonPrimitive)?.contentOrNull?.takeIf(String::isNotBlank) + ?: return@mapNotNull null + val app = (row["app"] as? JsonPrimitive)?.contentOrNull.orEmpty() + if (app.isNotBlank() && !text.contains(app, ignoreCase = true)) "$app · $text" else text + }?.distinct() + ?.joinToString(" · ") + ?.takeIf(String::isNotBlank) + /** Subtitle fallback: first line of the heuristic summary, then contexts. */ private fun genericSubtitle(call: ToolCall): String { val summary = ToolResultSummary.formatToolResultSummary(call.rawResult) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsScreen.kt new file mode 100644 index 00000000..77352cf9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsScreen.kt @@ -0,0 +1,278 @@ +package com.m57.hermescontrol.ui.connectors + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ExternalActivityLifecycleGuard +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.ConnectorAccount +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.notification.NotificationHelper +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState +import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.util.ConnectorUrlValidator + +@Composable +fun AccountConnectorsScreen( + onOpenDrawer: () -> Unit, + vm: AccountConnectorsViewModel = viewModel { AccountConnectorsViewModel() }, +) { + val state by vm.state.collectAsStateWithLifecycle() + val connection by vm.connectionState.collectAsStateWithLifecycle() + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val spacing = LocalSpacing.current + var pendingRemoval by remember(state.scope) { mutableStateOf(null) } + var browserOperation by remember(state.scope) { mutableStateOf(null) } + var departed by remember(state.scope) { mutableStateOf(false) } + DisposableEffect(lifecycleOwner, state.scope) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE && browserOperation != null) departed = true + if (event == Lifecycle.Event.ON_RESUME && departed) { + val opId = browserOperation + browserOperation = null + departed = false + ExternalActivityLifecycleGuard.externalActivityReturned() + if (opId != null) vm.onBrowserReturn(opId) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + if (browserOperation != null) ExternalActivityLifecycleGuard.externalActivityReturned() + } + } + HermesScaffold( + title = { Text(stringResource(R.string.account_connectors_title)) }, + navigationIcon = NavIcon.Menu(onOpenDrawer), + onRefresh = vm::refresh, + ) { + when { + state.loading -> { + LoadingState() + } + + state.error != null && state.catalog.isEmpty() && state.accounts.isEmpty() -> { + ErrorState(message = state.error.orEmpty(), onRetry = vm::refresh) + } + + state.catalog.isEmpty() && state.accounts.isEmpty() -> { + EmptyState(title = stringResource(R.string.account_connectors_no_accounts)) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.md), + ) { + state.error?.let { error -> item { Text(error, color = MaterialTheme.colorScheme.error) } } + items(state.catalog, key = { "catalog-${it.slug}" }) { connector -> + val current = state.connectorStates.firstOrNull { it.connector == connector.slug } + val policy = state.policy + val enabled = policy?.connectorEnabled(connector.slug) ?: current?.enabled + val locked = policy?.inherited?.any { !it.connectorEnabled(connector.slug) } == true + val editable = policy?.member != null && !state.busy && !locked + Card(Modifier.fillMaxWidth()) { + Column( + Modifier.padding(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Text(connector.name, style = MaterialTheme.typography.titleMedium) + if (connector.description.isNotBlank()) Text(connector.description) + Text( + current?.connectionStatus + ?: stringResource(R.string.account_connectors_not_connected), + ) + // Stack actions so large fonts and narrow phones never hide a control. + Button( + onClick = { vm.connect(connector.slug, reconnect = current?.isConnected == true) }, + enabled = !state.busy && connection.operation == null, + ) { + Text( + stringResource( + if (current?.isConnected == + true + ) { + R.string.account_connectors_reconnect + } else { + R.string.account_connectors_connect + }, + ), + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = enabled == true, + enabled = editable, + onCheckedChange = { vm.setEnabled(connector.slug, it) }, + ) + Text(stringResource(R.string.account_connectors_enabled)) + } + if (locked || + policy?.member == null + ) { + Text(stringResource(R.string.account_connectors_policy_locked)) + } + OutlinedButton( + onClick = { vm.loadTools(connector.slug) }, + enabled = connector.slug !in state.toolsLoading, + ) { Text(stringResource(R.string.account_connectors_tools)) } + state.tools[connector.slug]?.let { tools -> + if (tools.isEmpty()) Text(stringResource(R.string.account_connectors_no_tools)) + tools.forEach { tool -> + val disabled = + tool.slug in + policy + ?.member + ?.tools + ?.get(connector.slug) + .orEmpty() + val toolLocked = + policy?.inherited?.any { !it.toolEnabled(connector.slug, tool) } == true + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = enabled == true && !disabled && !toolLocked, + enabled = editable && enabled == true && !toolLocked, + onCheckedChange = { checked -> + val updated = + state.policy + ?.member + ?.tools + ?.get( + connector.slug, + ).orEmpty() + .toMutableSet() + if (checked) updated.remove(tool.slug) else updated.add(tool.slug) + vm.setDisabledTools(connector.slug, updated.toList()) + }, + ) + Column(Modifier.weight(1f)) { + Text(tool.name) + if (toolLocked) { + Text( + stringResource(R.string.account_connectors_policy_locked), + ) + } + Text(tool.description, style = MaterialTheme.typography.bodySmall) + Text(tool.facet, style = MaterialTheme.typography.labelSmall) + } + } + } + } + } + } + } + item { + Text( + stringResource(R.string.account_connectors_accounts), + style = MaterialTheme.typography.titleMedium, + ) + } + items(state.accounts, key = { "account-${it.connectionId}" }) { account -> + Card(Modifier.fillMaxWidth()) { + Column( + Modifier.padding(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Text(account.label, style = MaterialTheme.typography.titleMedium) + Text( + state.catalog.firstOrNull { it.slug == account.connector }?.name + ?: account.connector, + ) + Text(account.status) + account.statusReason?.let { Text(it) } + account.alias?.let { Text(it) } + OutlinedButton(onClick = { pendingRemoval = account }, enabled = !state.busy) { + Text(stringResource(R.string.account_connectors_remove)) + } + } + } + } + if (state.accounts.isEmpty()) item { Text(stringResource(R.string.account_connectors_no_accounts)) } + } + } + } + } + ConnectionSetupSheet( + state = connection, + onRespond = vm::respond, + onContinue = vm::continueOperation, + onDismiss = vm::continueOperation, + onOpenBrowser = { opId, url -> + if (ConnectorUrlValidator.isValidHttpsUrl(url)) { + try { + ExternalActivityLifecycleGuard.launchExternalActivity( + acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, + releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease, + prepareForBackground = { NotificationHelper.start(context) }, + cleanupAfterLaunchFailure = { NotificationHelper.stop(context) }, + ) { + browserOperation = opId + departed = false + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + } catch (_: Exception) { + browserOperation = null + ExternalActivityLifecycleGuard.externalActivityReturned() + vm.browserLaunchFailed() + } + } + }, + ) + pendingRemoval?.let { account -> + AlertDialog( + onDismissRequest = { pendingRemoval = null }, + title = { Text(stringResource(R.string.account_connectors_remove_confirm_title)) }, + text = { Text(stringResource(R.string.account_connectors_remove_confirm_message, account.label)) }, + confirmButton = { + TextButton(onClick = { + vm.remove(account.connectionId) + pendingRemoval = null + }, enabled = !state.busy) { Text(stringResource(R.string.account_connectors_remove)) } + }, + dismissButton = { + TextButton( + onClick = { pendingRemoval = null }, + ) { Text(stringResource(R.string.common_cancel)) } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt new file mode 100644 index 00000000..aacfe702 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt @@ -0,0 +1,268 @@ +package com.m57.hermescontrol.ui.connectors + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope +import com.m57.hermescontrol.data.model.AccountConnectorResult +import com.m57.hermescontrol.data.model.ConnectorAccount +import com.m57.hermescontrol.data.model.ConnectorCatalogEntry +import com.m57.hermescontrol.data.model.ConnectorItem +import com.m57.hermescontrol.data.model.ConnectorPolicy +import com.m57.hermescontrol.data.model.ConnectorTool +import com.m57.hermescontrol.data.ws.AccountConnectorRepository +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.ui.chat.ChatConnectionOperationDelegate +import com.m57.hermescontrol.ui.chat.ConnectionOperationRequester +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class AccountConnectorsState( + val loading: Boolean = true, + val error: String? = null, + val catalog: List = emptyList(), + val connectorStates: List = emptyList(), + val accounts: List = emptyList(), + val tools: Map> = emptyMap(), + val toolsLoading: Set = emptySet(), + val policy: ConnectorPolicy? = null, + val busy: Boolean = false, + val scope: DataScope? = null, +) + +class AccountConnectorsViewModel( + private val repository: AccountConnectorRepository = AccountConnectorRepository, + private val scopes: StateFlow = AuthManager.dataScopeFlow, + private val currentScope: () -> DataScope? = AuthManager::currentDataScope, + events: Flow = HermesWsClient.events, +) : ViewModel() { + private val _state = MutableStateFlow(AccountConnectorsState()) + val state: StateFlow = _state.asStateFlow() + private var generation = 0L + private var loadJob: Job? = null + private val scopedJobs = mutableListOf() + private val operation = + ChatConnectionOperationDelegate( + requester = ConnectionOperationRequester(repository::operationRequest), + accountOwned = true, + ) + val connectionState = operation.state + + init { + viewModelScope.launch { + scopes.collect { scope -> + generation++ + loadJob?.cancel() + scopedJobs.forEach(Job::cancel) + scopedJobs.clear() + operation.reset() + _state.value = AccountConnectorsState(scope = scope) + if (scope != null) load() else _state.update { it.copy(loading = false) } + } + } + viewModelScope.launch { + events.collect { event -> + if (event is WsEvent.GatewayReady) refresh() + val snapshot = (event as? WsEvent.ConnectionUpdate)?.snapshot ?: return@collect + // Never adopt an unsolicited account event: only our acknowledged operation belongs here. + if (isCurrent(generation) && snapshot.accountOwned && + snapshot.opId == + operation.state.value.operation + ?.opId && operation.acceptUpdate(snapshot) + ) { + if (snapshot.settled) load() + } + } + } + } + + private fun isCurrent(epoch: Long): Boolean = epoch == generation && _state.value.scope == currentScope() + + fun refresh() { + if (!_state.value.busy) { + load() + if (connectionState.value.operation != null) operationAction { } + } + } + + private fun load() { + loadJob?.cancel() + val epoch = generation + _state.update { it.copy(loading = true, error = null) } + loadJob = + viewModelScope.launch { + val list = async { repository.listConnectors() } + val catalog = async { repository.catalog() } + val accounts = async { repository.accounts() } + val policy = async { repository.policy() } + val listResult = list.await() + val catalogResult = catalog.await() + val accountsResult = accounts.await() + val policyResult = policy.await() + if (!isCurrent(epoch)) return@launch + _state.update { + it.copy( + loading = false, + error = + listResult.errorOrNull()?.message ?: catalogResult.errorMessage() + ?: accountsResult.errorMessage() ?: policyResult.errorMessage(), + catalog = catalogResult.valueOrNull().orEmpty(), + connectorStates = listResult.getOrNull().orEmpty(), + accounts = accountsResult.valueOrNull().orEmpty(), + policy = policyResult.valueOrNull(), + ) + } + } + } + + fun loadTools(slug: String) { + scopedJobs.removeAll { it.isCompleted } + if (slug in _state.value.toolsLoading || !isCurrent(generation)) return + val epoch = generation + _state.update { it.copy(toolsLoading = it.toolsLoading + slug) } + scopedJobs += + viewModelScope.launch { + val result = repository.tools(slug) + if (!isCurrent(epoch)) return@launch + _state.update { + it.copy( + tools = result.valueOrNull()?.let { tools -> it.tools + (slug to tools) } ?: it.tools, + toolsLoading = it.toolsLoading - slug, + error = result.errorMessage(), + ) + } + } + } + + fun connect( + slug: String, + reconnect: Boolean = false, + ) = mutate { + val epoch = generation + val result = repository.connect(listOf(slug), reconnect) + if (isCurrent(epoch)) { + result.valueOrNull()?.let { snapshot -> + operation.acceptRequest(snapshot) + if (snapshot.settled) load() + } + } + result.errorMessage() + } + + fun remove(connectionId: String) = + mutate { + val epoch = generation + val result = repository.remove(connectionId) + if (isCurrent(epoch) && result is AccountConnectorResult.Success) load() + result.errorMessage() + } + + fun setEnabled( + slug: String, + enabled: Boolean, + ) = updatePolicy { + repository.setConnectorEnabled( + slug, + enabled, + _state.value.policy + ?.member + ?.revision + .orEmpty(), + ) + } + + fun setDisabledTools( + slug: String, + disabled: List, + ) = updatePolicy { + repository.setDisabledTools( + slug, + disabled, + _state.value.policy + ?.member + ?.revision + .orEmpty(), + ) + } + + private fun updatePolicy(action: suspend () -> AccountConnectorResult) = + mutate { + val epoch = generation + val result = action() + if (isCurrent(epoch)) result.valueOrNull()?.let { policy -> _state.update { it.copy(policy = policy) } } + result.errorMessage() + } + + // Reserve synchronously, not inside launch: repeated taps must not start concurrent writes. + private fun mutate(action: suspend () -> String?) { + scopedJobs.removeAll { it.isCompleted } + if (_state.value.scope == null || _state.value.busy || _state.value.loading || !isCurrent(generation)) return + val epoch = generation + _state.update { it.copy(busy = true, error = null) } + scopedJobs += + viewModelScope.launch { + try { + if (!isCurrent(epoch)) return@launch + val error = action() + if (isCurrent(epoch)) _state.update { it.copy(busy = false, error = error) } + } catch (e: CancellationException) { + throw e + } finally { + if (isCurrent(epoch)) _state.update { it.copy(busy = false) } + } + } + } + + fun respond( + target: String, + env: Map, + approved: Boolean, + ) = operationAction { + operation.respond(target, env, approved) + } + + fun continueOperation() = operationAction { operation.continueOperation() } + + fun onBrowserReturn(opId: String) = operationAction { operation.wake(opId) } + + private fun operationAction(action: suspend () -> Unit) { + scopedJobs.removeAll { it.isCompleted } + if (!isCurrent(generation)) return + val epoch = generation + val opId = connectionState.value.operation?.opId ?: return + scopedJobs += + viewModelScope.launch { + action() + if (!isCurrent(epoch)) return@launch + val result = repository.operationStatus(opId) + if (!isCurrent(epoch)) return@launch + when (result) { + is AccountConnectorResult.Success -> { + operation.acceptUpdate(result.value) + if (result.value.settled) load() + } + + is AccountConnectorResult.Failure -> { + _state.update { it.copy(error = result.error.message) } + } + } + } + } + + fun browserLaunchFailed() { + _state.update { it.copy(error = "Unable to open the authorization browser.") } + } +} + +private fun AccountConnectorResult.valueOrNull(): T? = (this as? AccountConnectorResult.Success)?.value + +private fun AccountConnectorResult<*>.errorMessage(): String? = + (this as? AccountConnectorResult.Failure)?.error?.message diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index 6ea47d43..8669d16c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ListAlt import androidx.compose.material.icons.filled.ChatBubbleOutline +import androidx.compose.material.icons.filled.Extension import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Lock @@ -34,6 +35,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.AccountConnectorsScreen import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.SettingsAbout @@ -87,6 +89,12 @@ fun SettingsScreen( }, onClick = { NavigationController.navigateTo(SettingsConnection) }, ), + SettingsRow( + icon = Icons.Filled.Extension, + label = stringResource(R.string.screen_account_connectors), + summary = stringResource(R.string.account_connectors_summary), + onClick = { NavigationController.navigateTo(AccountConnectorsScreen) }, + ), SettingsRow( icon = Icons.Filled.Palette, label = stringResource(R.string.settings_sec_appearance), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52a2de07..d150e5ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,6 +87,7 @@ Cron System Settings + Connectors Profiles Bots Active Now @@ -248,6 +249,17 @@ Connection + Account connectors + Manage account authorizations and tool access without a chat session. + Authorized accounts + Connect + Tools + Enabled + Not connected + No authorized connector accounts. + Remove + Remove connector account? + Remove %1$s? You may need to authorize it again. Auth Token Stay connected in background Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery. @@ -1804,4 +1816,7 @@ The server did not provide capability details. Accept and update Session storage is damaged for %1$s. History may be incomplete; previously loaded sessions are kept. Restore or recover the database on the server, then refresh. + Reconnect + No tools available + Read-only policy restrictions diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt new file mode 100644 index 00000000..c13f06b0 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt @@ -0,0 +1,255 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.AccountConnectorResult +import com.m57.hermescontrol.data.model.ConnectorTool +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class AccountConnectorRepositoryTest { + @Before fun setup() { + mockkObject(AuthManager) + every { AuthManager.activeProfileId } returns MutableStateFlow("work") + every { AuthManager.currentDataScope() } returns null + } + + @After fun teardown() = unmockkAll() + + @Test fun `connect decodes operation snapshot and preserves authorization link without a session`() = + runTest { + val calls = mutableListOf>>() + val repo = + HermesAccountConnectorRepository { method, params -> + calls += method to params + operationFixture() + } + val result = repo.connect(listOf("drive"), true) as AccountConnectorResult.Success + assertTrue(result.value.accountOwned) + assertEquals("op-a", result.value.opId) + assertEquals( + "https://example.com/authorize", + result.value.targets + .single() + .safeConnectUrl, + ) + assertEquals( + mapOf( + "profile" to "work", + "owner" to mapOf("type" to "account"), + "connectors" to listOf("drive"), + "reconnect" to true, + ), + calls.single().second, + ) + assertEquals(WsMethods.CONNECTORS_CONNECT, calls.single().first) + } + + @Test fun `account operation respond status and wake retain profile and owner`() = + runTest { + val calls = mutableListOf>>() + val repo = + HermesAccountConnectorRepository { method, params -> + calls += method to params + operationFixture() + } + repo.operationStatus("op-a") + repo.operationRequest( + WsMethods.CONNECTION_RESPOND, + mapOf( + "op_id" to "op-a", + "result" to mapOf("settled_by" to "continue"), + ), + ) + repo.operationRequest(WsMethods.CONNECTORS_OPERATION_WAKE, mapOf("op_id" to "op-a")) + assertEquals( + listOf( + WsMethods.CONNECTORS_OPERATION_STATUS, + WsMethods.CONNECTION_RESPOND, + WsMethods.CONNECTORS_OPERATION_WAKE, + ), + calls.map { + it.first + }, + ) + calls.forEach { (_, params) -> + assertEquals("work", params["profile"]) + assertEquals(mapOf("type" to "account"), params["owner"]) + assertEquals("op-a", params["op_id"]) + assertFalse("session_id" in params) + } + } + + @Test fun `catalog accounts tools and removal use native account endpoints`() = + runTest { + val calls = mutableListOf>>() + val repo = + HermesAccountConnectorRepository { method, params -> + calls += method to params + when (method) { + WsMethods.CONNECTORS_CATALOG -> { + mapOf( + "connectors" to + listOf( + mapOf( + "slug" to "drive", + "name" to "Google Drive", + "description" to "Files", + "category" to "storage", + ), + ), + ) + } + + WsMethods.CONNECTORS_ACCOUNTS -> { + mapOf( + "accounts" to + listOf( + mapOf( + "connection_id" to "conn-1", + "connector" to "drive", + "label" to "Work account", + "status" to "active", + "active" to true, + "alias" to null, + ), + ), + ) + } + + WsMethods.CONNECTORS_TOOLS -> { + mapOf( + "tools" to + listOf( + mapOf( + "slug" to "delete", + "name" to "Delete file", + "hints" to listOf("destructive"), + ), + ), + ) + } + + WsMethods.CONNECTORS_ACCOUNTS_REMOVE -> { + mapOf( + "connection_id" to "conn-1", + "connector" to "drive", + "status" to "removed", + ) + } + + else -> { + error(method) + } + } + } + assertEquals("Google Drive", (repo.catalog() as AccountConnectorResult.Success).value.single().name) + assertEquals("Work account", (repo.accounts() as AccountConnectorResult.Success).value.single().label) + assertEquals( + "Delete file", + (repo.tools("drive", true) as AccountConnectorResult.Success).value.single().name, + ) + assertTrue(repo.remove("conn-1") is AccountConnectorResult.Success) + assertTrue(calls.all { "owner" !in it.second && it.second["profile"] == "work" }) + assertEquals(mapOf("profile" to "work", "slug" to "drive", "refresh" to true), calls[2].second) + assertEquals(mapOf("profile" to "work", "connection_id" to "conn-1"), calls[3].second) + } + + @Test fun `policy keeps member revision separate from effective and respects inherited rules`() = + runTest { + val repo = HermesAccountConnectorRepository { _, _ -> policyFixture() } + val policy = (repo.policy() as AccountConnectorResult.Success).value + assertEquals("effective-revision", policy.revision) + assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", policy.member?.revision) + assertEquals(listOf("mine"), policy.member?.tools?.get("drive")) + val destructive = ConnectorTool("delete", "Delete", "", "destructive", false, listOf("destructive")) + assertFalse(policy.inherited.single().toolEnabled("drive", destructive)) + assertFalse(policy.copy(mode = "deny-all").connectorEnabled("drive")) + assertFalse(policy.copy(mode = "future-mode").connectorEnabled("drive")) + } + + @Test fun `writes use observed member revision not effective revision or fresh overwritten version`() = + runTest { + val calls = mutableListOf>>() + val repo = + HermesAccountConnectorRepository { method, params -> + calls += method to params + if (method == WsMethods.CONNECTORS_POLICY_GET) { + policyFixture() + } else { + mapOf( + "revision" to "new-member-revision", + "effective" to mapOf("mode" to "deny-all", "revision" to "effective-revision"), + ) + } + } + assertTrue( + repo.setDisabledTools( + "drive", + listOf("delete"), + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + ) is AccountConnectorResult.Success, + ) + assertEquals(WsMethods.CONNECTORS_POLICY_SET, calls.first().first) + assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", calls.first().second["expected_revision"]) + assertEquals( + mapOf("type" to "tools", "connector" to "drive", "disabled_tools" to listOf("delete")), + calls.first().second["change"], + ) + } + + @Test fun `malformed authorization does not look like a successful connect`() = + runTest { + val repo = HermesAccountConnectorRepository { _, _ -> mapOf("status" to "initiated") } + assertTrue(repo.connect(listOf("drive")) is AccountConnectorResult.Failure) + } + + private fun operationFixture() = + mapOf( + "op_id" to "op-a", + "seq" to 1, + "deadline_at" to 2000000000.0, + "settled" to false, + "targets" to + listOf( + mapOf( + "name" to "drive", + "kind" to "connector", + "action" to "authorize", + "state" to "initiated", + "connect_url" to "https://example.com/authorize", + ), + ), + ) + + private fun policyFixture() = + mapOf( + "effective" to + mapOf( + "mode" to "deny", + "revision" to "effective-revision", + "disabled_connectors" to emptyList(), + ), + "layers" to + listOf( + mapOf( + "kind" to "member", + "revision" to "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "body" to mapOf("mode" to "deny", "tools" to mapOf("drive" to listOf("mine"))), + ), + mapOf( + "kind" to "org", + "revision" to "org-revision", + "body" to mapOf("mode" to "deny", "tags" to mapOf("disable" to listOf("destructive"))), + ), + ), + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ConnectorToolLabelsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ConnectorToolLabelsTest.kt new file mode 100644 index 00000000..6970008a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ConnectorToolLabelsTest.kt @@ -0,0 +1,40 @@ +package com.m57.hermescontrol.ui.chat.tool + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectorToolLabelsTest { + @Test fun `gateway envelope labels reach the visible header without replacing real tool arguments`() { + val content = """{ + "name":"tool_call", + "args":{"labels":["bug"]}, + "labels":[{"app":"GitHub","text":"Create issue"}], + "result":{} + }""" + val view = + com.m57.hermescontrol.ui.chat + .parseToolOutput(content, "tool_call", false) + assertEquals("GitHub · Create issue", view?.serverDisplayName) + assertEquals("GitHub · Create issue", view?.title) + } + + @Test fun `server connector labels title both live and restored tool calls`() { + val args = + Json.parseToJsonElement( + """{"hermes_tool_labels":[{"app":"Google Drive","text":"List files","kind":"connector"}]}""", + ) + val running = ToolViewBuilder.build("tool_call", args, null, running = true) + val restored = ToolViewBuilder.build("tool_call", args, Json.parseToJsonElement("{}")) + assertEquals("Google Drive · List files", running.title) + assertEquals(running.title, restored.title) + } + + @Test fun `malformed metadata and actual labels argument keep fallback title`() { + val args = Json.parseToJsonElement("""{"labels":["bug"],"hermes_tool_labels":[null,42,{"app":"GitHub"}]}""") + val view = ToolViewBuilder.build("tool_call", args, null) + assertTrue(view.title.isNotBlank()) + assertEquals(ToolViewBuilder.build("tool_call", null, null).title, view.title) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt new file mode 100644 index 00000000..e6ff4c9e --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt @@ -0,0 +1,207 @@ +package com.m57.hermescontrol.ui.connectors + +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.DataScope +import com.m57.hermescontrol.data.model.AccountConnectorResult +import com.m57.hermescontrol.data.model.ConnectorCatalogEntry +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorListResult +import com.m57.hermescontrol.data.model.ConnectorPolicy +import com.m57.hermescontrol.data.ws.AccountConnectorRepository +import com.m57.hermescontrol.data.ws.ConnectionOperationParser +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.data.ws.WsMethods +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AccountConnectorsViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val repo = mockk() + private val scopes = MutableStateFlow(DataScope("server", "http://host", "work", 1)) + private val events = MutableSharedFlow() + private lateinit var vm: AccountConnectorsViewModel + private val policy = ConnectorPolicy("effective", "unrestricted", emptyList(), emptyList(), emptyMap()) + + @Before fun setup() { + Dispatchers.setMain(dispatcher) + coEvery { repo.listConnectors() } returns ConnectorListResult.Success(true, emptyList()) + coEvery { repo.catalog() } returns + AccountConnectorResult.Success(listOf(ConnectorCatalogEntry("drive", "Google Drive", "Files", "storage"))) + coEvery { repo.accounts() } returns AccountConnectorResult.Success(emptyList()) + coEvery { repo.policy() } returns + AccountConnectorResult.Success(policy.copy(member = policy.copy(revision = "member"))) + coEvery { repo.operationStatus("op-a") } returns AccountConnectorResult.Success(snapshot()) + vm = AccountConnectorsViewModel(repo, scopes, { scopes.value }, events) + } + + @After fun teardown() { + vm.viewModelScope.cancel() + Dispatchers.resetMain() + } + + @Test fun `no session required and connect retains the browser operation`() = + runTest(dispatcher) { + advanceUntilIdle() + assertFalse(vm.state.value.loading) + assertEquals( + "Google Drive", + vm.state.value.catalog + .single() + .name, + ) + coEvery { repo.connect(listOf("drive"), false) } returns AccountConnectorResult.Success(snapshot()) + vm.connect("drive") + vm.connect("drive") + advanceUntilIdle() + coVerify(exactly = 1) { repo.connect(listOf("drive"), false) } + assertEquals( + "https://example.com/auth", + vm.connectionState.value.operation + ?.targets + ?.single() + ?.safeConnectUrl, + ) + } + + @Test fun `connect and remove errors remain visible rather than cleared by reload`() = + runTest(dispatcher) { + advanceUntilIdle() + coEvery { repo.connect(any(), any()) } returns + AccountConnectorResult.Failure(ConnectorError.RequestFailed("authorization failed")) + vm.connect("drive") + advanceUntilIdle() + assertEquals("authorization failed", vm.state.value.error) + coEvery { repo.remove("c1") } returns + AccountConnectorResult.Failure(ConnectorError.RequestFailed("remove failed")) + vm.remove("c1") + advanceUntilIdle() + assertEquals("remove failed", vm.state.value.error) + assertFalse(vm.state.value.busy) + } + + @Test fun `profile switch clears operation and rejects prior account update`() = + runTest(dispatcher) { + advanceUntilIdle() + coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot()) + vm.connect("drive") + advanceUntilIdle() + scopes.value = scopes.value?.copy(activeProfileId = "other") + advanceUntilIdle() + events.emit(WsEvent.ConnectionUpdate(snapshot().copy(seq = 2))) + advanceUntilIdle() + assertNull(vm.connectionState.value.operation) + assertEquals( + "other", + vm.state.value.scope + ?.activeProfileId, + ) + } + + @Test fun `late tool response cannot populate a new account scope`() = + runTest(dispatcher) { + advanceUntilIdle() + val pending = + CompletableDeferred>>() + coEvery { repo.tools("drive", false) } coAnswers { pending.await() } + vm.loadTools("drive") + dispatcher.scheduler.runCurrent() + scopes.value = scopes.value?.copy(inMemoryAuthGeneration = 2) + advanceUntilIdle() + pending.complete(AccountConnectorResult.Success(emptyList())) + advanceUntilIdle() + assertTrue( + vm.state.value.tools + .isEmpty(), + ) + assertTrue( + vm.state.value.toolsLoading + .isEmpty(), + ) + } + + @Test fun `continue answers account operation and settlement closes it`() = + runTest(dispatcher) { + advanceUntilIdle() + coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot()) + coEvery { repo.operationRequest(any(), any()) } returns mapOf("status" to "ok") + coEvery { repo.operationStatus("op-a") } returns + AccountConnectorResult.Success(snapshot().copy(seq = 2, settled = true)) + vm.connect("drive") + advanceUntilIdle() + vm.continueOperation() + advanceUntilIdle() + coVerify { + repo.operationRequest( + WsMethods.CONNECTION_RESPOND, + mapOf( + "owner" to mapOf("type" to "account"), + "op_id" to "op-a", + "result" to mapOf("settled_by" to "continue"), + ), + ) + } + assertNull(vm.connectionState.value.operation) + } + + @Test fun `session update never changes account operation`() = + runTest(dispatcher) { + advanceUntilIdle() + coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot()) + vm.connect("drive") + advanceUntilIdle() + events.emit( + WsEvent.ConnectionUpdate( + snapshot().copy(seq = 3, settled = true, accountOwned = false, sessionId = "chat"), + ), + ) + advanceUntilIdle() + assertEquals( + 1L, + vm.connectionState.value.operation + ?.seq, + ) + } + + private fun snapshot() = + checkNotNull( + ConnectionOperationParser.parse( + mapOf( + "owner" to mapOf("type" to "account"), + "op_id" to "op-a", + "seq" to 1, + "deadline_at" to 2000000000.0, + "settled" to false, + "targets" to + listOf( + mapOf( + "name" to "drive", + "kind" to "connector", + "action" to "authorize", + "state" to "initiated", + "connect_url" to "https://example.com/auth", + ), + ), + ), + ), + ) +}