This commit is contained in:
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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user