diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index c138a7b2..2aacce97 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -66,6 +66,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -423,88 +424,134 @@ private fun SystemBubble( } } -private data class ParsedToolOutput( +/** + * Cleanly parsed representation of a tool call, extracted from the + * tool.complete JSON payload (which contains both args and result). + */ +data class ParsedToolData( + val toolName: String = "", + val args: Map = emptyMap(), + val result: Map = emptyMap(), val isTerminal: Boolean = false, val stdout: String? = null, val stderr: String? = null, val exitCode: Int? = null, val error: String? = null, + val summaryText: String? = null, + val durationSec: Double? = null, val mainOutput: String? = null, - val genericFields: Map = emptyMap(), + val extraFields: Map = emptyMap(), + val isRunning: Boolean = false, ) -private fun parseToolOutput(content: String): ParsedToolOutput? { +/** + * Parses the tool.complete JSON payload into a [ParsedToolData]. + * The gateway sends a payload with `args` and `result` sub-objects + * (see tui_gateway/server.py `_on_tool_complete`). We extract both + * and use the tool name to build a one-line summary from the relevant arg. + */ +fun parseToolOutput( + content: String, + toolName: String?, + isRunning: Boolean, +): ParsedToolData? { val trimmed = content.trim() if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null return try { - val element = - com.google.gson.JsonParser - .parseString(trimmed) - if (element.isJsonObject) { - val obj = element.asJsonObject + val element = com.google.gson.JsonParser.parseString(trimmed) + if (!element.isJsonObject) return null + val obj = element.asJsonObject - val hasStdout = obj.has("stdout") - val hasStderr = obj.has("stderr") - val hasExitCode = obj.has("exit_code") || obj.has("exitCode") + // Resolve tool name — from parameter first, then from payload (old sessions) + val resolvedToolName = + toolName + ?: obj.get("name")?.takeIf { !it.isJsonNull }?.asString - if (hasStdout || hasStderr || hasExitCode) { - val stdout = - obj - .get("stdout") - ?.takeIf { !it.isJsonNull } - ?.asString - ?.takeIf { it.isNotEmpty() } - val stderr = - obj - .get("stderr") - ?.takeIf { !it.isJsonNull } - ?.asString - ?.takeIf { it.isNotEmpty() } - val exitCode = (obj.get("exit_code") ?: obj.get("exitCode"))?.takeIf { !it.isJsonNull }?.asInt - val error = - obj - .get("error") - ?.takeIf { !it.isJsonNull } - ?.asString - ?.takeIf { it.isNotEmpty() } + val config = ToolSchemaRegistry.getDisplayConfig(resolvedToolName) - ParsedToolOutput( - isTerminal = true, - stdout = stdout, - stderr = stderr, - exitCode = exitCode, - error = error, - ) + // Extract args sub-object if present (new tool.complete format) + val argsObj = obj.get("args")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject + val args: Map = + argsObj?.entrySet()?.associate { entry -> + entry.key to (if (entry.value.isJsonPrimitive) entry.value.asString else entry.value.toString()) + } ?: emptyMap() + + // Extract result sub-object if present, fall back to top-level (old format) + val resultObj = obj.get("result")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject + val dataSource = resultObj ?: obj + + // Build summary line from args (new format) or context field (old tool.start) + val summaryText = + if (config.summaryArgKey != null) { + val raw = args[config.summaryArgKey]?.toString() ?: "" + val truncated = if (raw.length > 100) raw.take(100) + "…" else raw + if (truncated.isNotBlank()) "${config.summaryPrefix}$truncated" else null } else { - val mainOutput = - (obj.get("output") ?: obj.get("result") ?: obj.get("content") ?: obj.get("text")) - ?.takeIf { !it.isJsonNull } - ?.let { - if (it.isJsonPrimitive) it.asString else it.toString() - }?.takeIf { it.isNotEmpty() } - - val genericFields = mutableMapOf() - obj.entrySet().forEach { (key, value) -> - if (key != "output" && key != "result" && key != "content" && key != "text" && !value.isJsonNull) { - val valStr = if (value.isJsonPrimitive) value.asString else value.toString() - if (valStr.isNotEmpty() && valStr != "null") { - genericFields[key] = valStr - } - } - } - - ParsedToolOutput( - mainOutput = mainOutput, - genericFields = genericFields, - ) + null ?: obj.get("context")?.takeIf { !it.isJsonNull }?.asString + ?.takeIf { it.isNotEmpty() } + ?.let { "${config.summaryPrefix}$it" } } - } else if (element.isJsonArray) { - val arr = element.asJsonArray - ParsedToolOutput( - mainOutput = arr.toString(), + + // Check if this looks like a terminal result (check dataSource for backward compat) + val hasStdout = dataSource.has("stdout") + val hasStderr = dataSource.has("stderr") + val hasExitCode = dataSource.has("exit_code") || dataSource.has("exitCode") + + if (hasStdout || hasStderr || hasExitCode) { + val stdout = dataSource.get("stdout")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() } + val stderr = dataSource.get("stderr")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() } + val exitCode = (dataSource.get("exit_code") ?: dataSource.get("exitCode"))?.takeIf { !it.isJsonNull }?.asInt + val error = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + + ParsedToolData( + toolName = resolvedToolName ?: "", + args = args, + result = dataSource.entrySet().associate { it.key to it.value.toString() }, + isTerminal = true, + stdout = stdout, + stderr = stderr, + exitCode = exitCode, + error = error, + summaryText = summaryText, + durationSec = duration, + isRunning = isRunning, ) } else { - null + // Generic tool display + val mainOutput = + dataSource + .let { r -> + (r.get("output") ?: r.get("result") ?: r.get("content") ?: r.get("text")) + ?.takeIf { !it.isJsonNull } + ?.let { if (it.isJsonPrimitive) it.asString else it.toString() } + ?.takeIf { it.isNotEmpty() } + } + + val extraFields = mutableMapOf() + dataSource.entrySet().forEach { (key, value) -> + if (key != "output" && key != "result" && key != "content" && key != "text" && + key != "name" && key != "tool_id" && key != "context" && !value.isJsonNull + ) { + val valStr = if (value.isJsonPrimitive) value.asString else value.toString() + if (valStr.isNotEmpty() && valStr != "null") { + extraFields[key] = valStr + } + } + } + + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + + ParsedToolData( + toolName = resolvedToolName ?: "", + args = args, + result = dataSource.entrySet().associate { it.key to it.value.toString() }, + summaryText = summaryText, + mainOutput = mainOutput, + extraFields = extraFields, + durationSec = duration, + isRunning = isRunning, + ) } } catch (e: Exception) { null @@ -512,12 +559,27 @@ private fun parseToolOutput(content: String): ParsedToolOutput? { } @Composable -private fun ParsedToolContent( - parsed: ParsedToolOutput, +private fun ExpandedToolContent( + parsed: ParsedToolData, contentColor: Color, ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + // Summary line at top of expanded view + if (parsed.summaryText != null) { + Text( + text = parsed.summaryText, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.7f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + Spacer(modifier = Modifier.height(2.dp)) + } + if (parsed.isTerminal) { + // ── Terminal output ── parsed.stdout?.let { Text( text = it, @@ -564,7 +626,19 @@ private fun ParsedToolContent( ) } } + + // Duration footer for terminal + parsed.durationSec?.let { dur -> + Text( + text = "Duration: ${"%.1f".format(dur)}s", + style = + MaterialTheme.typography.labelSmall.copy( + color = contentColor.copy(alpha = 0.5f), + ), + ) + } } else { + // ── Generic tool output ── parsed.mainOutput?.let { Text( text = it, @@ -575,7 +649,7 @@ private fun ParsedToolContent( ), ) } - parsed.genericFields.forEach { (key, value) -> + parsed.extraFields.forEach { (key, value) -> Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -598,6 +672,17 @@ private fun ParsedToolContent( ) } } + + // Duration footer + parsed.durationSec?.let { dur -> + Text( + text = "Duration: ${"%.1f".format(dur)}s", + style = + MaterialTheme.typography.labelSmall.copy( + color = contentColor.copy(alpha = 0.5f), + ), + ) + } } } } @@ -613,7 +698,22 @@ private fun ToolBubble( val chipColor = if (isDarkTheme) ToolChipColor else ToolChipColorLight val contentColor = if (isDarkTheme) Color.White else Color.Black - val parsedOutput = remember(message.content) { parseToolOutput(message.content) } + val parsed = + remember(message.content, message.toolName, message.toolStatus) { + parseToolOutput(message.content, message.toolName, message.toolStatus == ToolStatus.RUNNING) + } + val config = ToolSchemaRegistry.getDisplayConfig(message.toolName) + + val clipboardManager = LocalClipboardManager.current + var showCopyButton by remember { mutableStateOf(false) } + + // Auto-dismiss copy button after 4 seconds + LaunchedEffect(showCopyButton) { + if (showCopyButton) { + delay(4000) + showCopyButton = false + } + } Box( modifier = @@ -633,96 +733,131 @@ private fun ToolBubble( .animateContentSize() .padding(horizontal = 10.dp, vertical = 6.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - val (icon, tint) = - when (message.toolStatus) { - ToolStatus.RUNNING -> Icons.Filled.Build to MaterialTheme.colorScheme.secondary - ToolStatus.COMPLETED -> Icons.Filled.CheckCircle to StatusGreen - ToolStatus.FAILED -> Icons.Filled.Error to StatusRed - null -> Icons.Filled.Build to contentColor.copy(alpha = 0.6f) - } - - if (message.toolStatus == ToolStatus.RUNNING) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.secondary, - ) - } else { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = tint, - ) - } + // ── Header row: icon + tool name ── + HeaderRow(message, config, contentColor) + // ── Summary line (always visible when collapsed) ── + if (!expanded && parsed?.summaryText != null) { Text( - text = message.toolName ?: stringResource(R.string.chat_tool_fallback), + text = parsed.summaryText, style = - MaterialTheme.typography.labelMedium.copy( - color = contentColor, + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.7f), fontFamily = FontFamily.Monospace, + fontSize = 11.sp, ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp, start = 22.dp), ) } - if (expanded && message.content.isNotBlank()) { - Column(modifier = Modifier.padding(top = 6.dp)) { - if (parsedOutput != null && !showRawJson) { - ParsedToolContent(parsedOutput, contentColor) + // ── Expanded content ── + if (expanded) { + Spacer(modifier = Modifier.height(6.dp)) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.chat_tool_show_raw), - style = - MaterialTheme.typography.labelSmall.copy( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - ), - modifier = - Modifier - .testTag("chat_tool_show_raw") - .clickable(role = Role.Button) { showRawJson = true }, - ) - } else { - Text( - text = message.content, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.8f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - - if (parsedOutput != null) { - Spacer(modifier = Modifier.height(8.dp)) + if (showRawJson) { + // Raw JSON view — selectable + copy button + Box { + SelectionContainer { Text( - text = stringResource(R.string.chat_tool_show_parsed), + text = message.content, style = - MaterialTheme.typography.labelSmall.copy( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.8f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, ), - modifier = - Modifier - .testTag("chat_tool_show_parsed") - .clickable(role = Role.Button) { showRawJson = false }, ) } + CopyButton( + visible = showCopyButton, + textToCopy = message.content, + clipboardManager = clipboardManager, + onCopy = { showCopyButton = false }, + modifier = + Modifier + .align(Alignment.TopEnd) + .offset(x = 8.dp, y = (-8).dp), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.chat_tool_show_parsed), + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + modifier = + Modifier + .testTag("chat_tool_show_parsed") + .clickable(role = Role.Button) { showRawJson = false }, + ) + } else if (parsed != null) { + // Clean structured expanded view + Box { + SelectionContainer { + ExpandedToolContent(parsed, contentColor) + } + CopyButton( + visible = showCopyButton, + textToCopy = message.content, + clipboardManager = clipboardManager, + onCopy = { showCopyButton = false }, + modifier = + Modifier + .align(Alignment.TopEnd) + .offset(x = 8.dp, y = (-8).dp), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.chat_tool_show_raw), + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + modifier = + Modifier + .testTag("chat_tool_show_raw") + .clickable(role = Role.Button) { showRawJson = true }, + ) + } else { + // Unparseable content — show raw JSON, selectable + Box { + SelectionContainer { + Text( + text = message.content, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.8f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + CopyButton( + visible = showCopyButton, + textToCopy = message.content, + clipboardManager = clipboardManager, + onCopy = { showCopyButton = false }, + modifier = + Modifier + .align(Alignment.TopEnd) + .offset(x = 8.dp, y = (-8).dp), + ) } } } - // Timestamp — consistent with UserBubble/AssistantBubble - val textColor = if (isDarkTheme) Color.White else Color.Black + // ── Timestamp ── Text( text = formatTimestamp(message.timestamp), - color = textColor.copy(alpha = 0.5f), + color = contentColor.copy(alpha = 0.5f), style = MaterialTheme.typography.labelSmall, modifier = Modifier.align(Alignment.End).padding(top = 4.dp), ) @@ -731,6 +866,92 @@ private fun ToolBubble( } } +@Composable +private fun HeaderRow( + message: ChatMessage, + config: ToolDisplayConfig, + contentColor: Color, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Status icon or spinner + if (message.toolStatus == ToolStatus.RUNNING) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.secondary, + ) + } else { + val icon = + when (message.toolStatus) { + ToolStatus.COMPLETED -> Icons.Filled.CheckCircle + ToolStatus.FAILED -> Icons.Filled.Error + else -> Icons.Filled.Build + } + val tint = + when (message.toolStatus) { + ToolStatus.COMPLETED -> StatusGreen + ToolStatus.FAILED -> StatusRed + else -> contentColor.copy(alpha = 0.6f) + } + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = tint, + ) + } + + Text( + text = message.toolName ?: stringResource(R.string.chat_tool_fallback), + style = + MaterialTheme.typography.labelMedium.copy( + color = contentColor, + fontFamily = FontFamily.Monospace, + ), + ) + } +} + +@Composable +private fun CopyButton( + visible: Boolean, + textToCopy: String, + clipboardManager: androidx.compose.ui.platform.ClipboardManager, + onCopy: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut(), + modifier = modifier, + ) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), + shadowElevation = 6.dp, + ) { + IconButton( + onClick = { + clipboardManager.setText(AnnotatedString(textToCopy)) + onCopy() + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + /** * Simple rich text renderer supporting **bold**, `inline code`, ```code blocks```, and clickable URLs. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt new file mode 100644 index 00000000..3706f967 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt @@ -0,0 +1,111 @@ +package com.m57.hermescontrol.ui.chat + +/** + * Display configuration for a Hermes tool — controls the summary line, + * icon, and which arg field is shown when the bubble is collapsed. + * + * Based on the canonical tool schemas defined in the Hermes Agent source + * at /opt/hermes-agent/tools/ (each tool has a `*_SCHEMA` dict). + */ +data class ToolDisplayConfig( + val name: String, + /** Key into the `args` dict for the one-line summary (e.g. "command" for terminal). */ + val summaryArgKey: String? = null, + /** Prefix for the summary line (e.g. "$ " for terminal, "📄 " for read_file). */ + val summaryPrefix: String = "", + /** Emoji icon for this tool type. */ + val iconEmoji: String = "🔧", +) + +/** Maps tool names to their display config. Ordered by expected frequency of use. */ +object ToolSchemaRegistry { + private val knownTools: Map = + mapOf( + "terminal" to + ToolDisplayConfig( + name = "terminal", + summaryArgKey = "command", + summaryPrefix = "$ ", + iconEmoji = "💻", + ), + "read_file" to + ToolDisplayConfig( + name = "read_file", + summaryArgKey = "path", + summaryPrefix = "📄 ", + iconEmoji = "📄", + ), + "write_file" to + ToolDisplayConfig( + name = "write_file", + summaryArgKey = "path", + summaryPrefix = "✏️ ", + iconEmoji = "✏️", + ), + "patch" to + ToolDisplayConfig( + name = "patch", + summaryArgKey = "path", + summaryPrefix = "🔧 ", + iconEmoji = "🔧", + ), + "search_files" to + ToolDisplayConfig( + name = "search_files", + summaryArgKey = "pattern", + summaryPrefix = "🔍 ", + iconEmoji = "🔍", + ), + "web_search" to + ToolDisplayConfig( + name = "web_search", + summaryArgKey = "query", + summaryPrefix = "🌐 ", + iconEmoji = "🌐", + ), + "browser_navigate" to + ToolDisplayConfig( + name = "browser_navigate", + summaryArgKey = "url", + summaryPrefix = "🌍 ", + iconEmoji = "🌍", + ), + "browser_click" to + ToolDisplayConfig( + name = "browser_click", + summaryArgKey = "ref", + summaryPrefix = "🖱 ", + iconEmoji = "🖱", + ), + "browser_snapshot" to + ToolDisplayConfig( + name = "browser_snapshot", + summaryArgKey = null, + iconEmoji = "📋", + ), + "clarify" to + ToolDisplayConfig( + name = "clarify", + summaryArgKey = "question", + summaryPrefix = "💬 ", + iconEmoji = "💬", + ), + "delegate_task" to + ToolDisplayConfig( + name = "delegate_task", + summaryArgKey = "goal", + summaryPrefix = "🔄 ", + iconEmoji = "🔄", + ), + "execute_code" to + ToolDisplayConfig( + name = "execute_code", + summaryArgKey = "code", + summaryPrefix = "▶️ ", + iconEmoji = "▶️", + ), + ) + + fun getDisplayConfig(toolName: String?): ToolDisplayConfig = + toolName?.let { knownTools[it] } ?: ToolDisplayConfig(name = toolName ?: "tool") +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt new file mode 100644 index 00000000..433fbebe --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt @@ -0,0 +1,207 @@ +package com.m57.hermescontrol.ui.chat + +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.Test + +class ToolBubbleParsingTest { + // ── Terminal ────────────────────────────────────────────── + + @Test + fun testParseTerminal_withStdout() { + val json = + """{ + "tool_id": "call_001", + "name": "terminal", + "args": {"command": "npm run build"}, + "result": {"stdout": "Build succeeded!", "exit_code": 0}, + "duration_s": 5.2 + }""" + val parsed = parseToolOutput(json, "terminal", false) + assertNotNull(parsed) + assertEquals("terminal", parsed!!.toolName) + assertTrue(parsed.isTerminal) + assertEquals("Build succeeded!", parsed.stdout) + assertEquals(0, parsed.exitCode) + assertEquals(5.2, parsed.durationSec!!, 0.01) + assertEquals("$ npm run build", parsed.summaryText) + } + + @Test + fun testParseTerminal_withStderr() { + val json = + """{ + "tool_id": "call_002", + "name": "terminal", + "args": {"command": "rm -rf /"}, + "result": {"stderr": "Permission denied", "exit_code": 1} + }""" + val parsed = parseToolOutput(json, "terminal", false) + assertNotNull(parsed) + assertTrue(parsed!!.isTerminal) + assertEquals("Permission denied", parsed.stderr) + assertEquals(1, parsed.exitCode) + assertEquals("$ rm -rf /", parsed.summaryText) + } + + // ── File tools ──────────────────────────────────────────── + + @Test + fun testParseReadFile() { + val json = + """{ + "tool_id": "call_003", + "name": "read_file", + "args": {"path": "/opt/hermes/config.yaml"}, + "result": {"content": "port: 8080\nhost: 0.0.0.0"} + }""" + val parsed = parseToolOutput(json, "read_file", false) + assertNotNull(parsed) + assertFalse(parsed!!.isTerminal) + assertEquals("📄 /opt/hermes/config.yaml", parsed.summaryText) + assertEquals("port: 8080\nhost: 0.0.0.0", parsed.mainOutput) + } + + @Test + fun testParseWriteFile() { + val json = + """{ + "tool_id": "call_004", + "name": "write_file", + "args": {"path": "/tmp/output.txt"}, + "result": {"content": "File written successfully"} + }""" + val parsed = parseToolOutput(json, "write_file", false) + assertNotNull(parsed) + assertEquals("✏️ /tmp/output.txt", parsed!!.summaryText) + } + + @Test + fun testParsePatch() { + val json = + """{ + "tool_id": "call_005", + "name": "patch", + "args": {"path": "src/main.kt"}, + "result": {"success": true} + }""" + val parsed = parseToolOutput(json, "patch", false) + assertNotNull(parsed) + assertEquals("🔧 src/main.kt", parsed!!.summaryText) + } + + @Test + fun testParseSearchFiles() { + val json = + """{ + "tool_id": "call_006", + "name": "search_files", + "args": {"pattern": "*.kt", "path": "/opt/hermes-mobile"}, + "result": {"matches_data": ["file1.kt", "file2.kt"]} + }""" + val parsed = parseToolOutput(json, "search_files", false) + assertNotNull(parsed) + assertEquals("🔍 *.kt", parsed!!.summaryText) + } + + // ── Web / Browser ───────────────────────────────────────── + + @Test + fun testParseWebSearch() { + val json = + """{ + "tool_id": "call_007", + "name": "web_search", + "args": {"query": "Kotlin Coroutines guide"}, + "result": {"results_data": ["..."]} + }""" + val parsed = parseToolOutput(json, "web_search", false) + assertNotNull(parsed) + assertEquals("🌐 Kotlin Coroutines guide", parsed!!.summaryText) + } + + @Test + fun testParseBrowserNavigate() { + val json = + """{ + "tool_id": "call_008", + "name": "browser_navigate", + "args": {"url": "https://example.com"}, + "result": {"title": "Example Domain"} + }""" + val parsed = parseToolOutput(json, "browser_navigate", false) + assertNotNull(parsed) + assertEquals("🌍 https://example.com", parsed!!.summaryText) + } + + @Test + fun testParseClarify() { + val json = + """{ + "tool_id": "call_009", + "name": "clarify", + "args": {"question": "Which environment?"}, + "result": {"user_response": "staging"} + }""" + val parsed = parseToolOutput(json, "clarify", false) + assertNotNull(parsed) + assertEquals("💬 Which environment?", parsed!!.summaryText) + } + + // ── Running state ───────────────────────────────────────── + + @Test + fun testParseRunningState() { + val json = + """{"tool_id": "call_010", "name": "terminal", "args": {"command": "sleep 10"}}""" + val parsed = parseToolOutput(json, "terminal", true) + assertNotNull(parsed) + assertTrue(parsed!!.isRunning) + } + + // ── Unknown tool fallback ───────────────────────────────── + + @Test + fun testParseUnknownTool_noSummary() { + val json = + """{ + "tool_id": "call_011", + "name": "weird_tool", + "args": {"input": "data"}, + "result": {"output": "processed"} + }""" + val parsed = parseToolOutput(json, "weird_tool", false) + assertNotNull(parsed) + assertNull(parsed!!.summaryText) // no known config → no summary + assertEquals("processed", parsed.mainOutput) + } + + // ── Non-JSON content fallback ───────────────────────────── + + @Test + fun testParseNonJson_returnsNull() { + val parsed = parseToolOutput("just plain text", "terminal", false) + assertNull(parsed) + } + + // ── Empty args ──────────────────────────────────────────── + + @Test + fun testParseTerminal_withoutArgs() { + val json = + """{ + "tool_id": "call_012", + "name": "terminal", + "result": {"stdout": "done", "exit_code": 0} + }""" + val parsed = parseToolOutput(json, "terminal", false) + assertNotNull(parsed) + assertTrue(parsed!!.isTerminal) + assertEquals("done", parsed.stdout) + assertEquals(0, parsed.exitCode) + assertNull(parsed.summaryText) // no args → no summary + } +}