From 2e0fd46a4c1d9739cc594e03f4a6bffeabc55d42 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:35:25 +0300 Subject: [PATCH] feat(#1292): plugin settings/diagnostics and persisted edit previews (#1304) --- .../data/ws/PluginManageModels.kt | 102 +++++ .../data/ws/PluginManageRepository.kt | 52 +++ .../ui/chat/ChatServerMessageMapper.kt | 16 +- .../hermescontrol/ui/chat/ToolResultParser.kt | 17 +- .../ui/chat/tool/render/FileRenderers.kt | 12 + .../m57/hermescontrol/ui/common/DetailRows.kt | 2 +- .../hermescontrol/ui/plugins/PluginsScreen.kt | 26 +- .../ui/plugins/PluginsViewModel.kt | 213 ++++++++++ .../plugins/components/PluginDetailDialog.kt | 397 ++++++++++++++++++ app/src/main/res/values/strings.xml | 20 + .../data/ws/PluginManageModelsTest.kt | 376 +++++++++++++++++ .../data/ws/PluginManageRepositoryTest.kt | 236 +++++++++++ .../chat/PersistedEditPreviewHydrationTest.kt | 327 +++++++++++++++ 13 files changed, 1786 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/plugins/components/PluginDetailDialog.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageModelsTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/PersistedEditPreviewHydrationTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageModels.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageModels.kt new file mode 100644 index 00000000..a41b4c60 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageModels.kt @@ -0,0 +1,102 @@ +package com.m57.hermescontrol.data.ws + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +enum class PluginSettingFieldType { + @SerialName("string") + STRING, + + @SerialName("number") + NUMBER, + + @SerialName("boolean") + BOOLEAN, + + @SerialName("enum") + ENUM, + + @SerialName("secret") + SECRET, + + @SerialName("json") + JSON, +} + +@Serializable +data class PluginSettingField( + val key: String, + val type: PluginSettingFieldType, + val label: String, + val description: String = "", + val required: Boolean = false, + val value: JsonElement? = null, + val default: JsonElement? = null, + val choices: List? = null, + val env: String? = null, + @SerialName("has_value") val hasValue: Boolean? = null, +) + +@Serializable +enum class PluginServerState { + @SerialName("connected") + CONNECTED, + + @SerialName("app_not_running") + APP_NOT_RUNNING, + + @SerialName("endpoint_unavailable") + ENDPOINT_UNAVAILABLE, + + @SerialName("no_interactive_session") + NO_INTERACTIVE_SESSION, + + @SerialName("version_too_old") + VERSION_TOO_OLD, + + @SerialName("missing_app") + MISSING_APP, + + @SerialName("unknown") + UNKNOWN, +} + +@Serializable +data class PluginServerRow( + val name: String, + val state: PluginServerState, + val sentence: String = "", +) + +@Serializable +data class AgentPluginRow( + val name: String, + val key: String? = null, + val version: String = "", + val description: String = "", + val source: String = "", + val status: String = "", + val portable: Boolean = false, + @SerialName("install_dir") val installDir: String = "", + @SerialName("has_desktop_half") val hasDesktopHalf: Boolean = false, + val servers: List = emptyList(), + @SerialName("settings_schema") val settingsSchema: List? = null, + @SerialName("catalog_name") val catalogName: String? = null, + @SerialName("catalog_tier") val catalogTier: String? = null, + @SerialName("installed_sha") val installedSha: String? = null, + @SerialName("catalog_sha") val catalogSha: String? = null, + @SerialName("catalog_version") val catalogVersion: String? = null, + @SerialName("update_available") val updateAvailable: Boolean? = null, + @SerialName("pinned_sha") val pinnedSha: String? = null, +) + +@Serializable +data class PluginsManageSettingsResult( + val ok: Boolean = false, + val name: String? = null, + val written: List? = null, + val plugin: AgentPluginRow? = null, + val error: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt new file mode 100644 index 00000000..460e5bcf --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt @@ -0,0 +1,52 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement + +/** + * Manages plugin settings and inspection through the gateway's canonical `plugins.manage` RPC. + */ +class PluginManageRepository( + private val rpcRequest: suspend (String, Map) -> Any? = { method, params -> + val deferred = HermesWsClient.request(method, params.filterValues { it != null }.mapValues { it.value ?: "" }) + try { + deferred.await() + } catch (e: CancellationException) { + deferred.cancel(e) + throw e + } + }, +) { + suspend fun listPlugins(profile: String? = null): List { + val params = mutableMapOf("action" to "list") + if (!profile.isNullOrBlank()) { + params["profile"] = profile + } + val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params) + val element = result.toJsonElement() + val pluginsElement = (element as? JsonObject)?.get("plugins") ?: return emptyList() + return OkHttpProvider.json.decodeFromJsonElement>(pluginsElement) + } + + suspend fun saveSettings( + key: String, + values: Map, + profile: String? = null, + ): PluginsManageSettingsResult { + val params = + mutableMapOf( + "action" to "settings", + "key" to key, + "values" to JsonObject(values), + ) + if (!profile.isNullOrBlank()) { + params["profile"] = profile + } + val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params) + return OkHttpProvider.json.decodeFromJsonElement(result.toJsonElement()) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt index a451dcc0..1e67bfc2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt @@ -6,6 +6,8 @@ import com.m57.hermescontrol.data.model.AttachmentSource import com.m57.hermescontrol.data.model.SessionMessage import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.notification.ReplyNotificationTracker +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import kotlinx.serialization.json.JsonObject /** * Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s. @@ -179,7 +181,19 @@ internal fun mapServerMessages( ?: existingById[restId]?.timestamp ?: System.currentTimeMillis() - val rawContent = msg.visibleText + val rawContent = + if (role == MessageRole.TOOL && msg.display_metadata != null) { + // Enrich tool content with display_metadata so persisted edit previews + // (inline_diff) survive reload into the diff renderer + val parsedContent = ToolJson.parseMaybeObject(msg.content) + if (parsedContent != null && !parsedContent.containsKey("display_metadata")) { + JsonObject(parsedContent + ("display_metadata" to msg.display_metadata)).toString() + } else { + msg.visibleText + } + } else { + msg.visibleText + } // #1284: projected reasoning already excludes public commentary; it is authoritative. val rowReasoning = if (msg.hasDisplayReasoning) { 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 7e52589a..0d12fb09 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 @@ -52,7 +52,22 @@ fun parseToolOutput( } else { rawArgs } - val result = element["result"] as? JsonObject ?: element + val rawResultObj = element["result"] as? JsonObject + val displayMetadata = element["display_metadata"] + val result = + when { + rawResultObj != null && displayMetadata != null && !rawResultObj.containsKey("display_metadata") -> { + JsonObject(rawResultObj + ("display_metadata" to displayMetadata)) + } + + rawResultObj != null -> { + rawResultObj + } + + else -> { + element + } + } ToolViewBuilder.build( toolName = resolvedToolName ?: "tool", diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt index ae8095df..8a534ee0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt @@ -58,6 +58,18 @@ internal object FileEditSupport { } } + // Support persisted edit previews from display_metadata.tool_result_metadata.inline_diff or display_metadata.inline_diff + val displayMetadata = + record["display_metadata"] as? JsonObject + ?: ToolJson.parseMaybeObject(record["display_metadata"]) + val toolResultMeta = + displayMetadata?.get("tool_result_metadata") as? JsonObject + ?: ToolJson.parseMaybeObject(displayMetadata?.get("tool_result_metadata")) + val metaDiff = toolResultMeta?.get("inline_diff") ?: displayMetadata?.get("inline_diff") + if (metaDiff is JsonPrimitive && metaDiff.isString && metaDiff.content.trim().isNotEmpty()) { + return stripInlineDiffChrome(metaDiff.content) + } + return "" } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt index c5763b3b..bd9195ad 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt @@ -131,7 +131,7 @@ fun WebhookSubscription.toDetailRows(): List = * Map a free-form status string to a semantic tone. Recognises the app's * common status vocabulary (enabled/active/running/ok → success, etc.). */ -private fun toneForStatus(status: String?): DetailRowTone { +fun toneForStatus(status: String?): DetailRowTone { if (status.isNullOrBlank()) return DetailRowTone.NONE return when (status.lowercase()) { "enabled", "active", "running", "ok", "online", "connected" -> DetailRowTone.SUCCESS diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index d691fc92..757ed094 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -69,6 +69,7 @@ import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.toDetailRows import com.m57.hermescontrol.ui.plugins.components.CatalogPluginsView +import com.m57.hermescontrol.ui.plugins.components.PluginDetailDialog @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -273,7 +274,7 @@ fun PluginsScreen( plugin = plugin, state = state, viewModel = viewModel, - onClick = { showDetail = plugin }, + onClick = { viewModel.openPluginDetail(plugin) }, ) } @@ -289,7 +290,10 @@ fun PluginsScreen( ) } items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin -> - OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin }) + OrphanPluginCard( + plugin = plugin, + onClick = { viewModel.openPluginDetail(plugin) }, + ) } } } @@ -313,11 +317,19 @@ fun PluginsScreen( } } - showDetail?.let { plugin -> - DetailDialog( - title = plugin.name, - rows = plugin.toDetailRows(), - onDismiss = { showDetail = null }, + state.activeDetailPlugin?.let { plugin -> + PluginDetailDialog( + plugin = plugin, + agentPluginRow = state.pluginDetailRow, + isLoading = state.isPluginDetailLoading, + isSaving = state.isPluginDetailSaving, + edits = state.pluginDetailEdits, + onFieldChange = { key, value -> viewModel.setPluginDetailEdit(key, value) }, + onSaveSettings = { viewModel.savePluginSettings() }, + onDismiss = { + showDetail = null + viewModel.closePluginDetail() + }, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index a05daba1..137dcc7d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.local.SwrCache import com.m57.hermescontrol.data.model.AgentPluginInstallBody +import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.model.PluginCatalogEntry import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginProvidersPutRequest @@ -16,7 +17,11 @@ import com.m57.hermescontrol.data.model.ProviderOption 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.AgentPluginRow +import com.m57.hermescontrol.data.ws.PluginManageRepository import com.m57.hermescontrol.data.ws.PluginRemovalRepository +import com.m57.hermescontrol.data.ws.PluginSettingField +import com.m57.hermescontrol.data.ws.PluginSettingFieldType import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.safeLaunchAction import com.m57.hermescontrol.ui.common.safeLaunchLoad @@ -29,6 +34,12 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull enum class PluginsTab { INSTALLED, @@ -64,6 +75,12 @@ data class PluginsUiState( // Confirm remove dialog val removeConfirmPlugin: String? = null, val updateConsent: PluginUpdateConsent? = null, + // Active plugin detail with settings & diagnostics + val activeDetailPlugin: PluginInfo? = null, + val isPluginDetailLoading: Boolean = false, + val pluginDetailRow: AgentPluginRow? = null, + val pluginDetailEdits: Map = emptyMap(), + val isPluginDetailSaving: Boolean = false, // Rescan val rescanBusy: Boolean = false, // Catalog state @@ -85,6 +102,7 @@ data class PluginsUiState( class PluginsViewModel( private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(), + private val pluginManage: PluginManageRepository = PluginManageRepository(), ) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(PluginsUiState()) @@ -109,6 +127,9 @@ class PluginsViewModel( rowBusy = null, removeConfirmPlugin = null, updateConsent = null, + activeDetailPlugin = null, + pluginDetailRow = null, + pluginDetailEdits = emptyMap(), rescanBusy = false, ) } @@ -684,6 +705,198 @@ class PluginsViewModel( ) } + fun openPluginDetail(plugin: PluginInfo) { + _uiState.update { + it.copy( + activeDetailPlugin = plugin, + isPluginDetailLoading = true, + pluginDetailRow = null, + pluginDetailEdits = emptyMap(), + ) + } + viewModelScope.launch { + try { + val profile = AuthManager.activeProfileId.value + val rows = pluginManage.listPlugins(profile) + val matchingRow = rows.firstOrNull { it.key == plugin.name || it.name == plugin.name } + val initialEdits = mutableMapOf() + matchingRow?.settingsSchema?.forEach { field -> + if (field.type != PluginSettingFieldType.SECRET) { + val strVal = + when (val v = field.value ?: field.default) { + null -> "" + is JsonPrimitive -> v.content + else -> v.toString() + } + initialEdits[field.key] = strVal + } + } + _uiState.update { + it.copy( + isPluginDetailLoading = false, + pluginDetailRow = matchingRow, + pluginDetailEdits = initialEdits, + ) + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + _uiState.update { + it.copy( + isPluginDetailLoading = false, + pluginDetailRow = null, + ) + } + } + } + } + + fun closePluginDetail() { + _uiState.update { + it.copy( + activeDetailPlugin = null, + pluginDetailRow = null, + pluginDetailEdits = emptyMap(), + isPluginDetailSaving = false, + ) + } + } + + fun setPluginDetailEdit( + key: String, + value: String, + ) { + _uiState.update { + it.copy(pluginDetailEdits = it.pluginDetailEdits + (key to value)) + } + } + + fun savePluginSettings() { + val plugin = _uiState.value.pluginDetailRow ?: return + val schema = plugin.settingsSchema ?: return + val canonicalKey = plugin.key ?: plugin.name + val currentEdits = _uiState.value.pluginDetailEdits + + _uiState.update { it.copy(isPluginDetailSaving = true) } + viewModelScope.launch { + try { + val profile = AuthManager.activeProfileId.value + val valuesToSave = mutableMapOf() + + for (field in schema) { + val raw = currentEdits[field.key] ?: "" + when (field.type) { + PluginSettingFieldType.SECRET -> { + val envVar = field.env + if (raw.isNotBlank() && !envVar.isNullOrBlank()) { + val envResult = + safeApiCall { + ApiClient.hermesApi.updateEnvVar(EnvVarUpdate(key = envVar, value = raw)) + } + if (envResult is NetworkResult.Failure) { + throw IllegalStateException( + "Failed to update secret $envVar: ${envResult.error.message}", + ) + } + } + } + + PluginSettingFieldType.STRING -> { + valuesToSave[field.key] = JsonPrimitive(raw) + } + + PluginSettingFieldType.NUMBER -> { + val num = + raw.trim().toLongOrNull() + ?: raw.trim().toDoubleOrNull() + if (num != null) { + valuesToSave[field.key] = + if (num is Long) JsonPrimitive(num) else JsonPrimitive(num as Double) + } else if (raw.trim().isNotEmpty()) { + throw IllegalArgumentException("${field.label}: expected a number") + } + } + + PluginSettingFieldType.BOOLEAN -> { + valuesToSave[field.key] = JsonPrimitive(raw.trim().equals("true", ignoreCase = true)) + } + + PluginSettingFieldType.ENUM -> { + if (!field.choices.isNullOrEmpty() && raw.trim() !in field.choices) { + throw IllegalArgumentException( + "${field.label}: must be one of ${field.choices.joinToString()}", + ) + } + valuesToSave[field.key] = JsonPrimitive(raw.trim()) + } + + PluginSettingFieldType.JSON -> { + val parsed = + if (raw.trim().isEmpty()) { + JsonPrimitive("") + } else { + Json.parseToJsonElement(raw.trim()) + } + valuesToSave[field.key] = parsed + } + } + } + + if (valuesToSave.isNotEmpty()) { + val result = pluginManage.saveSettings(canonicalKey, valuesToSave, profile) + if (!result.ok) { + throw IllegalStateException(result.error ?: "Failed to save settings") + } + if (result.plugin != null) { + val refreshed = result.plugin + val newEdits = mutableMapOf() + refreshed.settingsSchema?.forEach { f -> + if (f.type != PluginSettingFieldType.SECRET) { + val strVal = + when (val v = f.value ?: f.default) { + null -> "" + is JsonPrimitive -> v.content + else -> v.toString() + } + newEdits[f.key] = strVal + } + } + _uiState.update { + it.copy( + isPluginDetailSaving = false, + pluginDetailRow = refreshed, + pluginDetailEdits = newEdits, + toastMessage = "Plugin settings saved", + ) + } + return@launch + } + } + + // If only secrets were saved or result plugin is null + _uiState.update { + it.copy( + isPluginDetailSaving = false, + pluginDetailEdits = + it.pluginDetailEdits.filterKeys { k -> + schema.none { f -> f.key == k && f.type == PluginSettingFieldType.SECRET } + }, + toastMessage = "Plugin settings saved", + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _uiState.update { + it.copy( + isPluginDetailSaving = false, + toastMessage = "Failed to save settings: ${e.message}", + ) + } + } + } + } + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/PluginDetailDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/PluginDetailDialog.kt new file mode 100644 index 00000000..68998a31 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/PluginDetailDialog.kt @@ -0,0 +1,397 @@ +package com.m57.hermescontrol.ui.plugins.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.PluginInfo +import com.m57.hermescontrol.data.ws.AgentPluginRow +import com.m57.hermescontrol.data.ws.PluginServerRow +import com.m57.hermescontrol.data.ws.PluginServerState +import com.m57.hermescontrol.data.ws.PluginSettingField +import com.m57.hermescontrol.data.ws.PluginSettingFieldType +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.common.DetailRow +import com.m57.hermescontrol.ui.common.DetailRowTone +import com.m57.hermescontrol.ui.common.ExposedDropdownField +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.common.toDetailRows +import com.m57.hermescontrol.ui.common.toneForStatus + +@Composable +fun PluginDetailDialog( + plugin: PluginInfo, + agentPluginRow: AgentPluginRow?, + isLoading: Boolean, + isSaving: Boolean, + edits: Map, + onFieldChange: (String, String) -> Unit, + onSaveSettings: () -> Unit, + onDismiss: () -> Unit, +) { + val statusColors = LocalHermesStatusColors.current + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + HermesScaffold( + title = { Text(plugin.name, style = MaterialTheme.typography.titleLarge) }, + navigationIcon = NavIcon.Back(onDismiss), + isRefreshing = false, + ) { _ -> + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Standard metadata rows + val rows = plugin.toDetailRows().filter { !it.value.isNullOrBlank() } + rows.forEachIndexed { index, (label, value, tone) -> + if (index > 0) { + HorizontalDivider() + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value ?: "", + style = MaterialTheme.typography.bodyMedium, + color = + when (tone) { + DetailRowTone.SUCCESS -> statusColors.success + DetailRowTone.WARNING -> statusColors.warning + DetailRowTone.ERROR -> statusColors.error + DetailRowTone.INFO -> statusColors.info + DetailRowTone.NEUTRAL -> statusColors.neutral + DetailRowTone.NONE -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + if (isLoading) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.plugins_settings_loading), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Diagnostics section (only if servers present) + val servers = agentPluginRow?.servers.orEmpty() + if (servers.isNotEmpty()) { + HorizontalDivider() + Text( + text = stringResource(R.string.plugins_diagnostics_heading), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + servers.forEach { server -> + PluginServerDiagnosticRow(server = server) + } + } + + // Settings section (only if schema present) + val schema = agentPluginRow?.settingsSchema.orEmpty() + if (schema.isNotEmpty()) { + HorizontalDivider() + Text( + text = stringResource(R.string.plugins_settings_heading), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + schema.forEach { field -> + PluginSettingFieldRow( + field = field, + value = edits[field.key] ?: "", + enabled = !isSaving, + onChange = { onFieldChange(field.key, it) }, + ) + } + + Button( + onClick = onSaveSettings, + enabled = !isSaving, + modifier = Modifier.align(Alignment.End), + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.plugins_settings_save)) + } + } + + // Close action + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_close)) + } + } + } + } + } + } +} + +@Composable +private fun PluginServerDiagnosticRow(server: PluginServerRow) { + val statusColors = LocalHermesStatusColors.current + val (statusLabel, statusColor, badgeType) = + when (server.state) { + PluginServerState.CONNECTED -> { + Triple( + stringResource(R.string.plugins_server_state_connected), + statusColors.success, + StatusBadgeType.SUCCESS, + ) + } + + PluginServerState.APP_NOT_RUNNING -> { + Triple( + stringResource(R.string.plugins_server_state_app_not_running), + statusColors.warning, + StatusBadgeType.WARNING, + ) + } + + PluginServerState.ENDPOINT_UNAVAILABLE -> { + Triple( + stringResource(R.string.plugins_server_state_endpoint_unavailable), + statusColors.warning, + StatusBadgeType.WARNING, + ) + } + + PluginServerState.NO_INTERACTIVE_SESSION -> { + Triple( + stringResource(R.string.plugins_server_state_no_interactive_session), + statusColors.warning, + StatusBadgeType.WARNING, + ) + } + + PluginServerState.VERSION_TOO_OLD -> { + Triple( + stringResource(R.string.plugins_server_state_version_too_old), + statusColors.error, + StatusBadgeType.ERROR, + ) + } + + PluginServerState.MISSING_APP -> { + Triple( + stringResource(R.string.plugins_server_state_missing_app), + statusColors.error, + StatusBadgeType.ERROR, + ) + } + + PluginServerState.UNKNOWN -> { + Triple( + stringResource(R.string.plugins_server_state_unknown), + statusColors.warning, + StatusBadgeType.WARNING, + ) + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = server.name, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + StatusBadge(text = statusLabel, status = badgeType) + } + if (server.sentence.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = server.sentence, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun PluginSettingFieldRow( + field: PluginSettingField, + value: String, + enabled: Boolean, + onChange: (String) -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = field.label.ifEmpty { field.key }, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + if (field.type == PluginSettingFieldType.SECRET) { + val isSet = field.hasValue == true + val badgeRes = + if (isSet) { + R.string.plugins_settings_secret_set + } else { + R.string.plugins_settings_secret_not_set + } + StatusBadge( + text = stringResource(badgeRes), + status = if (isSet) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING, + ) + } + } + if (field.description.isNotBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = field.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (field.type == PluginSettingFieldType.SECRET && !field.env.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(R.string.plugins_settings_secret_hint, field.env), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + + when (field.type) { + PluginSettingFieldType.ENUM -> { + ExposedDropdownField( + label = field.label.ifEmpty { field.key }, + options = field.choices.orEmpty().ifEmpty { listOf(value) }, + selectedValue = value, + onOptionSelected = onChange, + ) + } + + PluginSettingFieldType.BOOLEAN -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + checked = value.equals("true", ignoreCase = true), + onCheckedChange = { onChange(it.toString()) }, + enabled = enabled, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = + if (value.equals("true", ignoreCase = true)) { + stringResource(R.string.memory_provider_bool_on) + } else { + stringResource(R.string.memory_provider_bool_off) + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + else -> { + OutlinedTextField( + value = value, + onValueChange = onChange, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + label = { + Text( + if (field.type == PluginSettingFieldType.SECRET) { + stringResource(R.string.memory_provider_secret_label) + } else { + field.label.ifEmpty { field.key } + }, + ) + }, + singleLine = field.type != PluginSettingFieldType.JSON, + visualTransformation = + if (field.type == PluginSettingFieldType.SECRET) { + PasswordVisualTransformation() + } else { + androidx.compose.ui.text.input.VisualTransformation.None + }, + keyboardOptions = + if (field.type == PluginSettingFieldType.NUMBER) { + KeyboardOptions(keyboardType = KeyboardType.Decimal) + } else { + KeyboardOptions.Default + }, + ) + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d150e5ed..ae26e415 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1063,6 +1063,7 @@ No description provided. Update Uninstall + Settings Enable Install Remove @@ -1078,6 +1079,25 @@ Remove Plugin This will remove the "%1$s" plugin from your agent. Auth Required + Loading plugin settings… + Plugin Settings + Save Settings + Plugin settings saved + Failed to save settings: %1$s + Diagnostics + No diagnostics reported + Settings + set + not set + Stored in .env as %1$s (leave blank to keep) + No configurable settings + Connected + App not running + Endpoint unavailable + No interactive session + Version too old + App missing + Status unknown Auth command: %1$s Dashboard slots: %1$s Orphaned Dashboard Plugins diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageModelsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageModelsTest.kt new file mode 100644 index 00000000..0fb5cc06 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageModelsTest.kt @@ -0,0 +1,376 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class PluginManageModelsTest { + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes and encodes string type correctly`() { + val jsonString = + """ + { + "key": "api_host", + "type": "string", + "label": "API Host", + "description": "Hostname for API", + "required": true, + "value": "https://api.example.com", + "default": "https://localhost", + "choices": null, + "env": "API_HOST", + "has_value": true + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("api_host", field.key) + assertEquals(PluginSettingFieldType.STRING, field.type) + assertEquals("API Host", field.label) + assertEquals("Hostname for API", field.description) + assertTrue(field.required) + assertEquals(JsonPrimitive("https://api.example.com"), field.value) + assertEquals(JsonPrimitive("https://localhost"), field.default) + assertNull(field.choices) + assertEquals("API_HOST", field.env) + assertEquals(true, field.hasValue) + + val encoded = OkHttpProvider.json.encodeToString(field) + val roundTripped = OkHttpProvider.json.decodeFromString(encoded) + assertEquals(field, roundTripped) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes and encodes number type with numeric value and default`() { + val jsonString = + """ + { + "key": "port", + "type": "number", + "label": "Port Number", + "value": 8080, + "default": 3000 + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("port", field.key) + assertEquals(PluginSettingFieldType.NUMBER, field.type) + assertEquals(JsonPrimitive(8080), field.value) + assertEquals(JsonPrimitive(3000), field.default) + assertEquals("", field.description) + assertFalse(field.required) + assertNull(field.choices) + assertNull(field.env) + assertNull(field.hasValue) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes and encodes boolean type with default false`() { + val jsonString = + """ + { + "key": "enabled", + "type": "boolean", + "label": "Enabled", + "value": true, + "default": false + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("enabled", field.key) + assertEquals(PluginSettingFieldType.BOOLEAN, field.type) + assertEquals(JsonPrimitive(true), field.value) + assertEquals(JsonPrimitive(false), field.default) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes enum type with choices`() { + val jsonString = + """ + { + "key": "mode", + "type": "enum", + "label": "Execution Mode", + "choices": ["fast", "balanced", "thorough"], + "value": "fast", + "default": "balanced" + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("mode", field.key) + assertEquals(PluginSettingFieldType.ENUM, field.type) + assertEquals(listOf("fast", "balanced", "thorough"), field.choices) + assertEquals(JsonPrimitive("fast"), field.value) + assertEquals(JsonPrimitive("balanced"), field.default) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes secret type with masked or null value and has_value flag`() { + val jsonString = + """ + { + "key": "api_token", + "type": "secret", + "label": "API Token", + "value": null, + "env": "SECRET_TOKEN", + "has_value": true + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("api_token", field.key) + assertEquals(PluginSettingFieldType.SECRET, field.type) + assertEquals(null, field.value) + assertEquals("SECRET_TOKEN", field.env) + assertEquals(true, field.hasValue) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes json type with object or array values`() { + val jsonString = + """ + { + "key": "extra_config", + "type": "json", + "label": "Extra Config", + "value": {"timeout": 30, "retries": 3}, + "default": {} + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("extra_config", field.key) + assertEquals(PluginSettingFieldType.JSON, field.type) + assertTrue(field.value is JsonObject) + val valueObj = field.value as JsonObject + assertEquals(JsonPrimitive(30), valueObj["timeout"]) + assertEquals(JsonPrimitive(3), valueObj["retries"]) + assertTrue(field.default is JsonObject) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types (error case). + @Test + fun `PluginSettingField decoding fails on unknown type value`() { + val jsonString = + """ + { + "key": "invalid_field", + "type": "unknown_future_type", + "label": "Invalid" + } + """.trimIndent() + + try { + OkHttpProvider.json.decodeFromString(jsonString) + fail("Expected SerializationException for unknown type") + } catch (_: SerializationException) { + // expected + } + } + + // CONTRACT: PluginServerState (CONNECTED, APP_NOT_RUNNING, ENDPOINT_UNAVAILABLE, VERSION_TOO_OLD, MISSING_APP, UNKNOWN). + @Test + fun `PluginServerState decodes all enum variants from snake_case strings`() { + val expectedStates = + mapOf( + "connected" to PluginServerState.CONNECTED, + "app_not_running" to PluginServerState.APP_NOT_RUNNING, + "endpoint_unavailable" to PluginServerState.ENDPOINT_UNAVAILABLE, + "no_interactive_session" to PluginServerState.NO_INTERACTIVE_SESSION, + "version_too_old" to PluginServerState.VERSION_TOO_OLD, + "missing_app" to PluginServerState.MISSING_APP, + "unknown" to PluginServerState.UNKNOWN, + ) + + for ((serialized, expectedEnum) in expectedStates) { + val decoded = OkHttpProvider.json.decodeFromString("\"$serialized\"") + assertEquals("Mismatch for $serialized", expectedEnum, decoded) + val reEncoded = OkHttpProvider.json.encodeToString(expectedEnum) + assertEquals("\"$serialized\"", reEncoded) + } + } + + // CONTRACT: PluginServerState (CONNECTED, APP_NOT_RUNNING, ENDPOINT_UNAVAILABLE, VERSION_TOO_OLD, MISSING_APP, UNKNOWN) via PluginServerRow. + @Test + fun `PluginServerRow decodes name state and sentence correctly`() { + val jsonString = + """ + { + "name": "camofox_service", + "state": "app_not_running", + "sentence": "Camofox browser is not running on port 9333." + } + """.trimIndent() + + val row = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("camofox_service", row.name) + assertEquals(PluginServerState.APP_NOT_RUNNING, row.state) + assertEquals("Camofox browser is not running on port 9333.", row.sentence) + } + + // CONTRACT: AgentPluginRow decoding from backend plugins.manage list payload shape. + @Test + fun `AgentPluginRow decodes full payload shape with servers and settings schema`() { + val jsonString = + """ + { + "name": "camofox", + "key": "camofox-browser", + "version": "1.2.0", + "description": "Anti-detect browser automation", + "source": "installed", + "status": "active", + "portable": true, + "install_dir": "/home/user/.hermes/plugins/camofox", + "has_desktop_half": true, + "servers": [ + { + "name": "cdp", + "state": "connected", + "sentence": "CDP connected" + } + ], + "settings_schema": [ + { + "key": "port", + "type": "number", + "label": "CDP Port", + "value": 9222 + } + ], + "catalog_name": "camofox", + "catalog_tier": "community", + "installed_sha": "abc1234", + "catalog_sha": "def5678", + "catalog_version": "1.3.0", + "update_available": true, + "pinned_sha": "abc1234" + } + """.trimIndent() + + val row = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("camofox", row.name) + assertEquals("camofox-browser", row.key) + assertEquals("1.2.0", row.version) + assertEquals("Anti-detect browser automation", row.description) + assertEquals("installed", row.source) + assertEquals("status" to "active", "status" to row.status) + assertTrue(row.portable) + assertEquals("/home/user/.hermes/plugins/camofox", row.installDir) + assertTrue(row.hasDesktopHalf) + assertEquals(1, row.servers.size) + assertEquals("cdp", row.servers[0].name) + assertEquals(PluginServerState.CONNECTED, row.servers[0].state) + assertEquals(1, row.settingsSchema?.size) + assertEquals("port", row.settingsSchema?.get(0)?.key) + assertEquals(PluginSettingFieldType.NUMBER, row.settingsSchema?.get(0)?.type) + assertEquals("camofox", row.catalogName) + assertEquals("community", row.catalogTier) + assertEquals("abc1234", row.installedSha) + assertEquals("def5678", row.catalogSha) + assertEquals("1.3.0", row.catalogVersion) + assertEquals(true, row.updateAvailable) + assertEquals("abc1234", row.pinnedSha) + } + + // CONTRACT: AgentPluginRow decoding from backend plugins.manage list payload shape (defaults/nulls). + @Test + fun `AgentPluginRow decodes minimal required payload using defaults`() { + val jsonString = + """ + { + "name": "minimal_plugin" + } + """.trimIndent() + + val row = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("minimal_plugin", row.name) + assertNull(row.key) + assertEquals("", row.version) + assertEquals("", row.description) + assertEquals("", row.source) + assertEquals("", row.status) + assertFalse(row.portable) + assertEquals("", row.installDir) + assertFalse(row.hasDesktopHalf) + assertTrue(row.servers.isEmpty()) + assertNull(row.settingsSchema) + assertNull(row.catalogName) + assertNull(row.catalogTier) + assertNull(row.installedSha) + assertNull(row.catalogSha) + assertNull(row.catalogVersion) + assertNull(row.updateAvailable) + assertNull(row.pinnedSha) + } + + // CONTRACT: PluginsManageSettingsResult decoding happy path and error cases. + @Test + fun `PluginsManageSettingsResult decodes success with updated plugin and written keys`() { + val jsonString = + """ + { + "ok": true, + "name": "camofox", + "written": ["port", "headless"], + "plugin": { + "name": "camofox", + "version": "1.2.0" + }, + "error": null + } + """.trimIndent() + + val result = OkHttpProvider.json.decodeFromString(jsonString) + assertTrue(result.ok) + assertEquals("camofox", result.name) + assertEquals(listOf("port", "headless"), result.written) + assertNotNull(result.plugin) + assertEquals("camofox", result.plugin?.name) + assertNull(result.error) + } + + // CONTRACT: PluginsManageSettingsResult decoding happy path and error cases. + @Test + fun `PluginsManageSettingsResult decodes failure result with error message`() { + val jsonString = + """ + { + "ok": false, + "name": "camofox", + "error": "Validation failed: port must be an integer" + } + """.trimIndent() + + val result = OkHttpProvider.json.decodeFromString(jsonString) + assertFalse(result.ok) + assertEquals("camofox", result.name) + assertEquals("Validation failed: port must be an integer", result.error) + assertNull(result.written) + assertNull(result.plugin) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt new file mode 100644 index 00000000..e861aa1a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt @@ -0,0 +1,236 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.encodeToJsonElement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class PluginManageRepositoryTest { + // CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list. + @Test + fun `listPlugins without profile calls PLUGINS_MANAGE action list and decodes plugins list`() = + runTest { + var capturedMethod = "" + var capturedParams: Map = emptyMap() + + val mockPayload = + OkHttpProvider.json.parseToJsonElement( + """ + { + "plugins": [ + { + "name": "plugin-alpha", + "version": "1.0.0", + "status": "active" + }, + { + "name": "plugin-beta", + "version": "2.1.0", + "status": "disabled" + } + ] + } + """.trimIndent(), + ) + + val repository = + PluginManageRepository { method, params -> + capturedMethod = method + capturedParams = params + mockPayload + } + + val plugins = repository.listPlugins() + + assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) + assertEquals(mapOf("action" to "list"), capturedParams) + assertEquals(2, plugins.size) + assertEquals("plugin-alpha", plugins[0].name) + assertEquals("1.0.0", plugins[0].version) + assertEquals("active", plugins[0].status) + assertEquals("plugin-beta", plugins[1].name) + assertEquals("2.1.0", plugins[1].version) + assertEquals("disabled", plugins[1].status) + } + + // CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list. + @Test + fun `listPlugins with non-blank profile includes profile parameter in request`() = + runTest { + var capturedMethod = "" + var capturedParams: Map = emptyMap() + + val mockPayload = + OkHttpProvider.json.parseToJsonElement( + """ + { + "plugins": [] + } + """.trimIndent(), + ) + + val repository = + PluginManageRepository { method, params -> + capturedMethod = method + capturedParams = params + mockPayload + } + + val plugins = repository.listPlugins(profile = "work") + + assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) + assertEquals(mapOf("action" to "list", "profile" to "work"), capturedParams) + assertTrue(plugins.isEmpty()) + } + + // CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list. + @Test + fun `listPlugins returns empty list when plugins key is missing or response is not a JsonObject`() = + runTest { + val emptyObjRepo = + PluginManageRepository { _, _ -> + OkHttpProvider.json.parseToJsonElement("{}") + } + assertTrue(emptyObjRepo.listPlugins().isEmpty()) + + val nonObjRepo = + PluginManageRepository { _, _ -> + OkHttpProvider.json.parseToJsonElement("[]") + } + assertTrue(nonObjRepo.listPlugins().isEmpty()) + + val nullRepo = + PluginManageRepository { _, _ -> + null + } + assertTrue(nullRepo.listPlugins().isEmpty()) + } + + // CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result. + @Test + fun `saveSettings without profile calls PLUGINS_MANAGE with action settings key and JsonObject values`() = + runTest { + var capturedMethod = "" + var capturedParams: Map = emptyMap() + + val mockResponse = + OkHttpProvider.json.parseToJsonElement( + """ + { + "ok": true, + "name": "camofox", + "written": ["port", "headless"], + "plugin": { + "name": "camofox", + "version": "1.0.0" + } + } + """.trimIndent(), + ) + + val repository = + PluginManageRepository { method, params -> + capturedMethod = method + capturedParams = params + mockResponse + } + + val valuesToSave: Map = + mapOf( + "port" to JsonPrimitive(9222), + "headless" to JsonPrimitive(true), + ) + + val result = repository.saveSettings(key = "camofox", values = valuesToSave) + + assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) + assertEquals("settings", capturedParams["action"]) + assertEquals("camofox", capturedParams["key"]) + assertEquals(JsonObject(valuesToSave), capturedParams["values"]) + assertFalse(capturedParams.containsKey("profile")) + + assertTrue(result.ok) + assertEquals("camofox", result.name) + assertEquals(listOf("port", "headless"), result.written) + assertNotNull(result.plugin) + assertEquals("camofox", result.plugin?.name) + assertNull(result.error) + } + + // CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result. + @Test + fun `saveSettings with profile includes profile in request and handles error result`() = + runTest { + var capturedMethod = "" + var capturedParams: Map = emptyMap() + + val mockResponse = + OkHttpProvider.json.parseToJsonElement( + """ + { + "ok": false, + "name": "camofox", + "error": "Port already in use" + } + """.trimIndent(), + ) + + val repository = + PluginManageRepository { method, params -> + capturedMethod = method + capturedParams = params + mockResponse + } + + val valuesToSave: Map = + mapOf( + "port" to JsonPrimitive(8080), + ) + + val result = repository.saveSettings(key = "camofox", values = valuesToSave, profile = "staging") + + assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) + assertEquals( + mapOf( + "action" to "settings", + "key" to "camofox", + "values" to JsonObject(valuesToSave), + "profile" to "staging", + ), + capturedParams, + ) + + assertFalse(result.ok) + assertEquals("camofox", result.name) + assertEquals("Port already in use", result.error) + assertNull(result.written) + assertNull(result.plugin) + } + + // CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result (error case). + @Test + fun `saveSettings throws SerializationException when response is malformed`() = + runTest { + val repository = + PluginManageRepository { _, _ -> + OkHttpProvider.json.parseToJsonElement("{\"ok\": \"not-a-boolean\"}") + } + + try { + repository.saveSettings("camofox", emptyMap()) + fail("Expected SerializationException when response JSON is malformed") + } catch (_: SerializationException) { + // Expected + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/PersistedEditPreviewHydrationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/PersistedEditPreviewHydrationTest.kt new file mode 100644 index 00000000..57bf99ef --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/PersistedEditPreviewHydrationTest.kt @@ -0,0 +1,327 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.SessionMessage +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests verifying that persisted edit previews survive transcript loading into [ChatMessage] + * and subsequent tool diff parsing via [parseToolOutput]. + */ +class PersistedEditPreviewHydrationTest { + private val json = Json { ignoreUnknownKeys = true } + + private fun parseJson(raw: String): JsonElement = json.parseToJsonElement(raw) + + private fun toolSessionMessage( + content: String, + displayMetadata: JsonElement? = null, + id: Int = 101, + toolCallId: String = "call-101", + ): SessionMessage = + SessionMessage( + id = id, + role = "tool", + content = JsonPrimitive(content), + tool_call_id = toolCallId, + display_metadata = displayMetadata, + ) + + private fun mapSingle(msg: SessionMessage): ChatMessage = + mapServerMessages( + sessionId = "sess-edit-preview", + messages = listOf(msg), + offset = 0, + latestPaging = true, + liveMessages = emptyList(), + ).single() + + // ── Contract 1: ChatServerMessageMapper enrichment ──────────────────────── + + @Test + fun mapServerMessages_enrichesToolContentWithDisplayMetadataInlineDiff() { + val diffPayload = + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1 +1 @@\n-old\n+new" + } + } + """.trimIndent() + val originalContent = """{"output":"File updated successfully"}""" + val serverMsg = + toolSessionMessage( + content = originalContent, + displayMetadata = parseJson(diffPayload), + ) + + val chatMessage = mapSingle(serverMsg) + + val enriched = parseJson(chatMessage.content) as? JsonObject + assertNotNull("Expected enriched content to parse as JSON object", enriched) + assertEquals( + "File updated successfully", + (enriched?.get("output") as? JsonPrimitive)?.content, + ) + val displayMeta = enriched?.get("display_metadata") as? JsonObject + assertNotNull("Expected display_metadata to be added to content", displayMeta) + val toolResultMeta = displayMeta?.get("tool_result_metadata") as? JsonObject + assertNotNull("Expected tool_result_metadata inside display_metadata", toolResultMeta) + val diff = (toolResultMeta?.get("inline_diff") as? JsonPrimitive)?.content + assertEquals("@@ -1 +1 @@\n-old\n+new", diff) + } + + @Test + fun mapServerMessages_doesNotEnrichWhenDisplayMetadataIsNull() { + val originalContent = """{"output":"no metadata"}""" + val serverMsg = + toolSessionMessage( + content = originalContent, + displayMetadata = null, + ) + + val chatMessage = mapSingle(serverMsg) + + assertEquals(originalContent, chatMessage.content) + } + + @Test + fun mapServerMessages_doesNotEnrichWhenRoleIsNotTool() { + val metadata = + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1 +1 @@\n-old\n+new" + } + } + """.trimIndent() + val originalContent = "Assistant says hello" + val serverMsg = + SessionMessage( + id = 202, + role = "assistant", + content = JsonPrimitive(originalContent), + display_metadata = parseJson(metadata), + ) + + val chatMessage = mapSingle(serverMsg) + + assertEquals(originalContent, chatMessage.content) + } + + @Test + fun mapServerMessages_doesNotOverwriteExistingDisplayMetadataInContent() { + val content = + """ + { + "output": "ok", + "display_metadata": { + "existing": true + } + } + """.trimIndent() + val externalMetadata = + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1 +1 @@\n-old\n+new" + } + } + """.trimIndent() + val serverMsg = + toolSessionMessage( + content = content, + displayMetadata = parseJson(externalMetadata), + ) + + val chatMessage = mapSingle(serverMsg) + + val parsed = parseJson(chatMessage.content) as? JsonObject + assertNotNull(parsed) + val meta = parsed?.get("display_metadata") as? JsonObject + assertEquals("true", (meta?.get("existing") as? JsonPrimitive)?.content) + assertNull(meta?.get("tool_result_metadata")) + } + + @Test + fun mapServerMessages_ignoresNonJsonObjectContent() { + val nonJsonContent = "Plain text output from tool" + val metadata = + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1 +1 @@\n-old\n+new" + } + } + """.trimIndent() + val serverMsg = + toolSessionMessage( + content = nonJsonContent, + displayMetadata = parseJson(metadata), + ) + + val chatMessage = mapSingle(serverMsg) + + assertEquals(nonJsonContent, chatMessage.content) + } + + // ── Contract 2: ToolResultParser / FileRenderers inline diff extraction ── + + @Test + fun parseToolOutput_extractsInlineDiffFromNestedToolResultMetadata() { + val rawDiff = "@@ -1,3 +1,3 @@\n-old line\n+new line" + val content = + """ + { + "name": "patch", + "args": { + "path": "app/src/main/Utils.kt" + }, + "display_metadata": { + "tool_result_metadata": { + "inline_diff": "@@ -1,3 +1,3 @@\n-old line\n+new line" + } + } + } + """.trimIndent() + + val view = parseToolOutput(content = content, toolName = "patch", isRunning = false) + + assertNotNull("Expected parseToolOutput to return a ToolView", view) + assertEquals(rawDiff, view?.inlineDiff) + assertEquals("app/src/main/Utils.kt", view?.diffPath) + assertEquals(1, view?.diffStats?.added) + assertEquals(1, view?.diffStats?.removed) + } + + @Test + fun parseToolOutput_extractsInlineDiffFromFlatDisplayMetadata() { + val rawDiff = "--- a/test.kt\n+++ b/test.kt\n@@ -1 +1 @@\n-alpha\n+beta" + val content = + """ + { + "name": "edit_file", + "args": { + "path": "test.kt" + }, + "display_metadata": { + "inline_diff": "--- a/test.kt\n+++ b/test.kt\n@@ -1 +1 @@\n-alpha\n+beta" + } + } + """.trimIndent() + + val view = parseToolOutput(content = content, toolName = "edit_file", isRunning = false) + + assertNotNull(view) + assertEquals(rawDiff, view?.inlineDiff) + assertEquals("test.kt", view?.diffPath) + assertEquals(1, view?.diffStats?.added) + assertEquals(1, view?.diffStats?.removed) + } + + @Test + fun parseToolOutput_extractsInlineDiffFromResultObjectDirectly() { + val rawDiff = "@@ -1 +1 @@\n-before\n+after" + val content = + """ + { + "name": "patch", + "args": { + "path": "file.txt" + }, + "result": { + "inline_diff": "@@ -1 +1 @@\n-before\n+after" + } + } + """.trimIndent() + + val view = parseToolOutput(content = content, toolName = "patch", isRunning = false) + + assertNotNull(view) + assertEquals(rawDiff, view?.inlineDiff) + assertEquals("file.txt", view?.diffPath) + assertEquals(1, view?.diffStats?.added) + assertEquals(1, view?.diffStats?.removed) + } + + @Test + fun parseToolOutput_absentOrEmptyMetadataYieldsNullInlineDiff() { + val emptyDiffContent = + """ + { + "name": "patch", + "args": { + "path": "file.txt" + }, + "display_metadata": { + "tool_result_metadata": { + "inline_diff": "" + } + } + } + """.trimIndent() + val noDiffView = parseToolOutput(content = emptyDiffContent, toolName = "patch", isRunning = false) + assertNotNull(noDiffView) + assertNull(noDiffView?.inlineDiff) + + val missingDiffContent = + """ + { + "name": "patch", + "args": { + "path": "file.txt" + }, + "result": { + "success": true + } + } + """.trimIndent() + val missingDiffView = parseToolOutput(content = missingDiffContent, toolName = "patch", isRunning = false) + assertNotNull(missingDiffView) + assertNull(missingDiffView?.inlineDiff) + } + + // ── End-to-end integration: Transcript hydration -> ToolView diff parsing ─ + + @Test + fun endToEnd_hydratedTranscriptProducesToolViewWithInlineDiff() { + val serverDiff = "@@ -1,2 +1,2 @@\n-val x = 1\n+val x = 2" + val sessionMsg = + toolSessionMessage( + content = """{"name":"patch","args":{"path":"src/Config.kt"},"result":{"success":true}}""", + displayMetadata = + parseJson( + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1,2 +1,2 @@\n-val x = 1\n+val x = 2" + } + } + """.trimIndent(), + ), + ) + + val chatMessage = mapSingle(sessionMsg) + assertTrue(chatMessage.content.contains("display_metadata")) + + val toolView = + parseToolOutput( + content = chatMessage.content, + toolName = "patch", + isRunning = false, + ) + + assertNotNull("Expected parseToolOutput to succeed for hydrated message content", toolView) + assertEquals(serverDiff, toolView?.inlineDiff) + assertEquals("src/Config.kt", toolView?.diffPath) + assertEquals(1, toolView?.diffStats?.added) + assertEquals(1, toolView?.diffStats?.removed) + } +}