feat(#210): schema-driven tool bubble redesign with summary line and copy support (#356)

* feat(#210): add ToolSchemaRegistry with display config for each tool

* feat(#210): three-state ToolBubble with summary line, clean expanded view, copy support

* test(#210): add ToolBubbleParsingTest for schema-aware parsing

* fix(#210): CopyButton align modifier moved to call sites

* fix(#210): null-safety on durationSec in test

* fix(#210): backward compat for old session tool data formats

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-25 00:27:49 +03:00
1 parent 1bd56270b3
commit 067f4500aa
3 files changed
+677 -138

No files matched your search

@@ -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<String, Any?> = emptyMap(),
val result: Map<String, Any?> = 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<String, String> = emptyMap(),
val extraFields: Map<String, String> = 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<String, Any?> =
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<String, String>()
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<String, String>()
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.
*/
@@ -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<String, ToolDisplayConfig> =
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")
}
@@ -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
}
}