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.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 isTerminal: Boolean = false,
val stdout: String? = null, val stdout: String? = null,
val stderr: String? = null, val stderr: String? = null,
val exitCode: Int? = null, val exitCode: Int? = null,
val error: String? = null, val error: String? = null,
val summaryText: String? = null,
val durationSec: Double? = null,
val mainOutput: String? = 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() val trimmed = content.trim()
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null
return try { return try {
val element = val element = com.google.gson.JsonParser.parseString(trimmed)
com.google.gson.JsonParser if (!element.isJsonObject) return null
.parseString(trimmed) val obj = element.asJsonObject
if (element.isJsonObject) {
val obj = element.asJsonObject
val hasStdout = obj.has("stdout") // Resolve tool name — from parameter first, then from payload (old sessions)
val hasStderr = obj.has("stderr") val resolvedToolName =
val hasExitCode = obj.has("exit_code") || obj.has("exitCode") toolName
?: obj.get("name")?.takeIf { !it.isJsonNull }?.asString
if (hasStdout || hasStderr || hasExitCode) { val config = ToolSchemaRegistry.getDisplayConfig(resolvedToolName)
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() }
ParsedToolOutput( // Extract args sub-object if present (new tool.complete format)
isTerminal = true, val argsObj = obj.get("args")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject
stdout = stdout, val args: Map<String, Any?> =
stderr = stderr, argsObj?.entrySet()?.associate { entry ->
exitCode = exitCode, entry.key to (if (entry.value.isJsonPrimitive) entry.value.asString else entry.value.toString())
error = error, } ?: 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 { } else {
val mainOutput = null ?: obj.get("context")?.takeIf { !it.isJsonNull }?.asString
(obj.get("output") ?: obj.get("result") ?: obj.get("content") ?: obj.get("text")) ?.takeIf { it.isNotEmpty() }
?.takeIf { !it.isJsonNull } ?.let { "${config.summaryPrefix}$it" }
?.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,
)
} }
} else if (element.isJsonArray) {
val arr = element.asJsonArray // Check if this looks like a terminal result (check dataSource for backward compat)
ParsedToolOutput( val hasStdout = dataSource.has("stdout")
mainOutput = arr.toString(), 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 { } 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) { } catch (e: Exception) {
null null
@@ -512,12 +559,27 @@ private fun parseToolOutput(content: String): ParsedToolOutput? {
} }
@Composable @Composable
private fun ParsedToolContent( private fun ExpandedToolContent(
parsed: ParsedToolOutput, parsed: ParsedToolData,
contentColor: Color, contentColor: Color,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { 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) { if (parsed.isTerminal) {
// ── Terminal output ──
parsed.stdout?.let { parsed.stdout?.let {
Text( Text(
text = it, 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 { } else {
// ── Generic tool output ──
parsed.mainOutput?.let { parsed.mainOutput?.let {
Text( Text(
text = it, text = it,
@@ -575,7 +649,7 @@ private fun ParsedToolContent(
), ),
) )
} }
parsed.genericFields.forEach { (key, value) -> parsed.extraFields.forEach { (key, value) ->
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp), 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 chipColor = if (isDarkTheme) ToolChipColor else ToolChipColorLight
val contentColor = if (isDarkTheme) Color.White else Color.Black 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( Box(
modifier = modifier =
@@ -633,96 +733,131 @@ private fun ToolBubble(
.animateContentSize() .animateContentSize()
.padding(horizontal = 10.dp, vertical = 6.dp), .padding(horizontal = 10.dp, vertical = 6.dp),
) { ) {
Row( // ── Header row: icon + tool name ──
verticalAlignment = Alignment.CenterVertically, HeaderRow(message, config, contentColor)
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,
)
}
// ── Summary line (always visible when collapsed) ──
if (!expanded && parsed?.summaryText != null) {
Text( Text(
text = message.toolName ?: stringResource(R.string.chat_tool_fallback), text = parsed.summaryText,
style = style =
MaterialTheme.typography.labelMedium.copy( MaterialTheme.typography.bodySmall.copy(
color = contentColor, color = contentColor.copy(alpha = 0.7f),
fontFamily = FontFamily.Monospace, 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()) { // ── Expanded content ──
Column(modifier = Modifier.padding(top = 6.dp)) { if (expanded) {
if (parsedOutput != null && !showRawJson) { Spacer(modifier = Modifier.height(6.dp))
ParsedToolContent(parsedOutput, contentColor)
Spacer(modifier = Modifier.height(8.dp)) if (showRawJson) {
Text( // Raw JSON view — selectable + copy button
text = stringResource(R.string.chat_tool_show_raw), Box {
style = SelectionContainer {
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))
Text( Text(
text = stringResource(R.string.chat_tool_show_parsed), text = message.content,
style = style =
MaterialTheme.typography.labelSmall.copy( MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.primary, color = contentColor.copy(alpha = 0.8f),
textDecoration = TextDecoration.Underline, 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 // ── Timestamp ──
val textColor = if (isDarkTheme) Color.White else Color.Black
Text( Text(
text = formatTimestamp(message.timestamp), text = formatTimestamp(message.timestamp),
color = textColor.copy(alpha = 0.5f), color = contentColor.copy(alpha = 0.5f),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
modifier = Modifier.align(Alignment.End).padding(top = 4.dp), 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. * 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
}
}