feat(#1292): plugin settings/diagnostics and persisted edit previews (#1304)

This commit is contained in:
m57 authored and GitHub committed 2026-09-27 17:35:25 +03:00
1 parent ef54804b72
commit 2e0fd46a4c
13 files changed
+1786 -10

No files matched your search

@@ -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<String>? = 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<PluginServerRow> = emptyList(),
@SerialName("settings_schema") val settingsSchema: List<PluginSettingField>? = 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<String>? = null,
val plugin: AgentPluginRow? = null,
val error: String? = null,
)
@@ -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<String, Any?>) -> 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<AgentPluginRow> {
val params = mutableMapOf<String, Any?>("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<List<AgentPluginRow>>(pluginsElement)
}
suspend fun saveSettings(
key: String,
values: Map<String, JsonElement>,
profile: String? = null,
): PluginsManageSettingsResult {
val params =
mutableMapOf<String, Any?>(
"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<PluginsManageSettingsResult>(result.toJsonElement())
}
}
@@ -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) {
@@ -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",
@@ -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 ""
}
@@ -131,7 +131,7 @@ fun WebhookSubscription.toDetailRows(): List<DetailRow> =
* 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
@@ -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()
},
)
}
@@ -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<String, String> = 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<String, String>()
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<String, JsonElement>()
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<String, String>()
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) }
}
@@ -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<String, String>,
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
},
)
}
}
}
}
}
+20
View File
@@ -1063,6 +1063,7 @@
<string name="plugins_no_desc">No description provided.</string>
<string name="plugins_action_update">Update</string>
<string name="plugins_action_uninstall">Uninstall</string>
<string name="plugins_action_settings">Settings</string>
<string name="plugins_action_enable">Enable</string>
<string name="plugins_action_install">Install</string>
<string name="plugins_action_remove">Remove</string>
@@ -1078,6 +1079,25 @@
<string name="plugins_remove_confirm_title">Remove Plugin</string>
<string name="plugins_remove_confirm_text">This will remove the "%1$s" plugin from your agent.</string>
<string name="plugins_auth_required">Auth Required</string>
<string name="plugins_settings_loading">Loading plugin settings…</string>
<string name="plugins_settings_title">Plugin Settings</string>
<string name="plugins_settings_save">Save Settings</string>
<string name="plugins_settings_saved">Plugin settings saved</string>
<string name="plugins_settings_save_failed">Failed to save settings: %1$s</string>
<string name="plugins_diagnostics_heading">Diagnostics</string>
<string name="plugins_diagnostics_empty">No diagnostics reported</string>
<string name="plugins_settings_heading">Settings</string>
<string name="plugins_settings_secret_set">set</string>
<string name="plugins_settings_secret_not_set">not set</string>
<string name="plugins_settings_secret_hint">Stored in .env as %1$s (leave blank to keep)</string>
<string name="plugins_settings_empty">No configurable settings</string>
<string name="plugins_server_state_connected">Connected</string>
<string name="plugins_server_state_app_not_running">App not running</string>
<string name="plugins_server_state_endpoint_unavailable">Endpoint unavailable</string>
<string name="plugins_server_state_no_interactive_session">No interactive session</string>
<string name="plugins_server_state_version_too_old">Version too old</string>
<string name="plugins_server_state_missing_app">App missing</string>
<string name="plugins_server_state_unknown">Status unknown</string>
<string name="plugins_auth_command_hint">Auth command: %1$s</string>
<string name="plugins_dashboard_slots">Dashboard slots: %1$s</string>
<string name="plugins_orphan_heading">Orphaned Dashboard Plugins</string>
@@ -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<PluginSettingField>(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<PluginSettingField>(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<PluginSettingField>(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<PluginSettingField>(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<PluginSettingField>(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<PluginSettingField>(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<PluginSettingField>(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<PluginSettingField>(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<PluginServerState>("\"$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<PluginServerRow>(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<AgentPluginRow>(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<AgentPluginRow>(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<PluginsManageSettingsResult>(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<PluginsManageSettingsResult>(jsonString)
assertFalse(result.ok)
assertEquals("camofox", result.name)
assertEquals("Validation failed: port must be an integer", result.error)
assertNull(result.written)
assertNull(result.plugin)
}
}
@@ -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<String, Any?> = 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<String, Any?> = 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<String, Any?> = 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<String, JsonElement> =
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<String, Any?> = 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<String, JsonElement> =
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
}
}
}
@@ -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)
}
}