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

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

No files matched your search

@@ -0,0 +1,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)
}
}