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.model.SessionMessage
|
||||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||||
import com.m57.hermescontrol.notification.ReplyNotificationTracker
|
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.
|
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
|
||||||
@@ -179,7 +181,19 @@ internal fun mapServerMessages(
|
|||||||
?: existingById[restId]?.timestamp
|
?: existingById[restId]?.timestamp
|
||||||
?: System.currentTimeMillis()
|
?: 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.
|
// #1284: projected reasoning already excludes public commentary; it is authoritative.
|
||||||
val rowReasoning =
|
val rowReasoning =
|
||||||
if (msg.hasDisplayReasoning) {
|
if (msg.hasDisplayReasoning) {
|
||||||
|
|||||||
@@ -52,7 +52,22 @@ fun parseToolOutput(
|
|||||||
} else {
|
} else {
|
||||||
rawArgs
|
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(
|
ToolViewBuilder.build(
|
||||||
toolName = resolvedToolName ?: "tool",
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ fun WebhookSubscription.toDetailRows(): List<DetailRow> =
|
|||||||
* Map a free-form status string to a semantic tone. Recognises the app's
|
* Map a free-form status string to a semantic tone. Recognises the app's
|
||||||
* common status vocabulary (enabled/active/running/ok → success, etc.).
|
* 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
|
if (status.isNullOrBlank()) return DetailRowTone.NONE
|
||||||
return when (status.lowercase()) {
|
return when (status.lowercase()) {
|
||||||
"enabled", "active", "running", "ok", "online", "connected" -> DetailRowTone.SUCCESS
|
"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.listItemSpacing
|
||||||
import com.m57.hermescontrol.ui.common.toDetailRows
|
import com.m57.hermescontrol.ui.common.toDetailRows
|
||||||
import com.m57.hermescontrol.ui.plugins.components.CatalogPluginsView
|
import com.m57.hermescontrol.ui.plugins.components.CatalogPluginsView
|
||||||
|
import com.m57.hermescontrol.ui.plugins.components.PluginDetailDialog
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -273,7 +274,7 @@ fun PluginsScreen(
|
|||||||
plugin = plugin,
|
plugin = plugin,
|
||||||
state = state,
|
state = state,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onClick = { showDetail = plugin },
|
onClick = { viewModel.openPluginDetail(plugin) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +290,10 @@ fun PluginsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin ->
|
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 ->
|
state.activeDetailPlugin?.let { plugin ->
|
||||||
DetailDialog(
|
PluginDetailDialog(
|
||||||
title = plugin.name,
|
plugin = plugin,
|
||||||
rows = plugin.toDetailRows(),
|
agentPluginRow = state.pluginDetailRow,
|
||||||
onDismiss = { showDetail = null },
|
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.DataScope
|
||||||
import com.m57.hermescontrol.data.local.SwrCache
|
import com.m57.hermescontrol.data.local.SwrCache
|
||||||
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
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.PluginCatalogEntry
|
||||||
import com.m57.hermescontrol.data.model.PluginInfo
|
import com.m57.hermescontrol.data.model.PluginInfo
|
||||||
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
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.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.ToastHost
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchAction
|
import com.m57.hermescontrol.ui.common.safeLaunchAction
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||||
@@ -29,6 +34,12 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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 {
|
enum class PluginsTab {
|
||||||
INSTALLED,
|
INSTALLED,
|
||||||
@@ -64,6 +75,12 @@ data class PluginsUiState(
|
|||||||
// Confirm remove dialog
|
// Confirm remove dialog
|
||||||
val removeConfirmPlugin: String? = null,
|
val removeConfirmPlugin: String? = null,
|
||||||
val updateConsent: PluginUpdateConsent? = 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
|
// Rescan
|
||||||
val rescanBusy: Boolean = false,
|
val rescanBusy: Boolean = false,
|
||||||
// Catalog state
|
// Catalog state
|
||||||
@@ -85,6 +102,7 @@ data class PluginsUiState(
|
|||||||
|
|
||||||
class PluginsViewModel(
|
class PluginsViewModel(
|
||||||
private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(),
|
private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(),
|
||||||
|
private val pluginManage: PluginManageRepository = PluginManageRepository(),
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(PluginsUiState())
|
private val _uiState = MutableStateFlow(PluginsUiState())
|
||||||
@@ -109,6 +127,9 @@ class PluginsViewModel(
|
|||||||
rowBusy = null,
|
rowBusy = null,
|
||||||
removeConfirmPlugin = null,
|
removeConfirmPlugin = null,
|
||||||
updateConsent = null,
|
updateConsent = null,
|
||||||
|
activeDetailPlugin = null,
|
||||||
|
pluginDetailRow = null,
|
||||||
|
pluginDetailEdits = emptyMap(),
|
||||||
rescanBusy = false,
|
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() {
|
override fun clearToast() {
|
||||||
_uiState.update { it.copy(toastMessage = null) }
|
_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_no_desc">No description provided.</string>
|
||||||
<string name="plugins_action_update">Update</string>
|
<string name="plugins_action_update">Update</string>
|
||||||
<string name="plugins_action_uninstall">Uninstall</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_enable">Enable</string>
|
||||||
<string name="plugins_action_install">Install</string>
|
<string name="plugins_action_install">Install</string>
|
||||||
<string name="plugins_action_remove">Remove</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_title">Remove Plugin</string>
|
||||||
<string name="plugins_remove_confirm_text">This will remove the "%1$s" plugin from your agent.</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_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_auth_command_hint">Auth command: %1$s</string>
|
||||||
<string name="plugins_dashboard_slots">Dashboard slots: %1$s</string>
|
<string name="plugins_dashboard_slots">Dashboard slots: %1$s</string>
|
||||||
<string name="plugins_orphan_heading">Orphaned Dashboard Plugins</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