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

@@ -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
}
}