feat(chat): rewire tool bubbles onto the desktop engine (stage 3) (#765)

* feat(chat): rewire tool bubbles onto the desktop engine (stage 3)

parseToolOutput is now a thin adapter: it normalizes the payload shape
(args/result sub-objects, legacy top-level fallback, name resolution)
and delegates all per-tool formatting to ToolViewBuilder.

- ToolResultParser.kt: 1325 lines of bespoke formatters → 45-line adapter
- ParsedToolData deleted; ToolBubble renders ToolView directly:
  collapsed = emoji + engine title (+countLabel) + subtitle line;
  expanded = error → $ command / stdout / stderr / exit code →
  DiffViewCard → search hits → detail → duration footer
- Gson-compat shims, formatTodoToolOutput, toIntOrNull and all
  per-tool formatters died with the old parser (engine is idiomatic kotlinx)
- Kept mobile-only UI: progress preview, SecurityRiskChip, emoji
  prefixes, raw-JSON toggle, DiffViewCard, copy
- ToolViewBuilder fixes found during the rewire: numberValue now accepts
  numeric strings (TS Number() semantics — gateway ships exit_code as
  string/float), memory title keeps its target suffix

ToolBubbleParsingTest rewritten as adapter-contract tests (14 cases);
per-tool behavior lives in ToolViewBuilderTest. Verified: ktlint +
full testDebugUnitTest.

* fix(chat): dedupe tool name in collapsed bubble summary

The header row already shows the tool name, but the collapsed summary
showed emoji + engine title — and for ~11 tools the engine title is just
the generic humanized name (fact_store -> "Fact Store", todo -> "Todo"),
so the bubble displayed the tool identity twice.

composeToolSummaryLines now drops the title when it equals the generic
humanized name and merges the subtitle into the emoji line instead
("🧠 Fact added (ID: 5)"), restoring the pre-engine one-line style.
Descriptive titles ("Searched \"cats\"", "Ran ls", "Read a.kt L10-14")
stay on line one with the subtitle on line two. A lone emoji with no
subtitle renders nothing.

ToolViewBuilder.genericTitleFor exposes the generic title for the
comparison. 9 new unit tests.

---------

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andhy4ri authored and GitHub committed 2026-08-01 23:23:11 +03:00
1 parent 100a82dd4f
commit 982f8aec4e
6 files changed
+507 -1766

No files matched your search

@@ -1,351 +1,166 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.components.DiffLineType
import com.m57.hermescontrol.ui.chat.components.parseDiffText
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
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
/**
* Adapter-contract tests for [parseToolOutput] — the thin wrapper over the
* desktop-ported engine. Shape normalization lives here; per-tool content
* behavior is covered by ToolViewBuilderTest in ui.chat.tool.
*/
class ToolBubbleParsingTest {
// ── Terminal ──────────────────────────────────────────────
@Test
fun testParseTerminal_withOutput() {
val json =
"""{
"tool_id": "call_001",
"name": "terminal",
"args": {"command": "echo hellp"},
"result": {"output": "hellp", "exit_code": 0, "error": null},
"duration_s": 0.06
}"""
val parsed = parseToolOutput(json, "terminal", false)
assertNotNull(parsed)
assertEquals("terminal", parsed!!.toolName)
assertTrue(parsed.isTerminal)
assertEquals("hellp", parsed.stdout)
assertEquals("hellp", parsed.mainOutput)
assertEquals(0, parsed.exitCode)
assertEquals(0.06, parsed.durationSec!!, 0.01)
assertEquals("$ echo hellp", parsed.summaryText)
fun `parses new-format payload with args and result`() {
val view =
parseToolOutput(
"""{"args":{"command":"ls"},"result":{"output":"wiring.tsx","exit_code":0}}""",
"terminal",
false,
)
assertNotNull(view)
assertEquals("wiring.tsx", view!!.stdout)
assertEquals(0, view.exitCode)
assertEquals(ToolViewStatus.SUCCESS, view.status)
assertEquals("Ran ls", view.title)
}
@Test
fun testParseTerminal_legacyStdout() {
val json =
"""{
"tool_id": "call_001b",
"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)
assertTrue(parsed!!.isTerminal)
assertEquals("Build succeeded!", parsed.stdout)
assertEquals("Build succeeded!", parsed.mainOutput)
assertEquals(0, parsed.exitCode)
assertEquals(5.2, parsed.durationSec!!, 0.01)
assertEquals("$ npm run build", parsed.summaryText)
fun `float exit code does not null the parse`() {
val view = parseToolOutput("""{"result":{"output":"ok","exit_code":0.0}}""", "terminal", false)
assertNotNull(view)
assertEquals(0, view!!.exitCode)
}
@Test
fun testParseTerminal_floatExitCode_isNotDropped() {
// Backend emits exit_code as a FLOAT (0.0), not an int. asInt on a
// "0.0" primitive throws NumberFormatException, which previously
// nulled the whole parse -> raw JSON. Reproduces the live bug.
val json =
"""{
"tool_id": "call_001d",
"name": "terminal",
"args": {"command": "echo hi"},
"result": {"output": "hi", "exit_code": 0.0, "error": null}
}"""
val parsed = parseToolOutput(json, "terminal", false)
assertNotNull("float exit_code must not null the parse", parsed)
assertTrue(parsed!!.isTerminal)
assertEquals("hi", parsed.stdout)
assertEquals(0, parsed.exitCode)
fun `string exit code parses like the desktop engine`() {
val view = parseToolOutput("""{"result":{"output":"ok","exit_code":"0.0"}}""", "terminal", false)
assertNotNull(view)
assertEquals(0, view!!.exitCode)
}
@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.stdout)
assertEquals(1, parsed.exitCode)
assertEquals("$ rm -rf /", parsed.summaryText)
}
fun `non-zero exit without output becomes an error`() {
val view = parseToolOutput("""{"result":{"exit_code":1}}""", "terminal", false)
// ── 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)
assertNotNull(view)
assertEquals(ToolViewStatus.ERROR, view!!.status)
assertEquals("Command failed with exit code 1.", view.error)
}
@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)
fun `terminal error field surfaces`() {
val view = parseToolOutput("""{"result":{"error":"Invalid command: frobnicate"}}""", "terminal", false)
assertNotNull(view)
assertEquals(ToolViewStatus.ERROR, view!!.status)
assertTrue(view.error!!.contains("Invalid command"))
}
@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)
fun `old-format payload without result sub-object parses`() {
val view = parseToolOutput("""{"name":"terminal","output":"hi","exit_code":0}""", null, false)
assertNotNull(view)
assertEquals("hi", view!!.stdout)
assertEquals(0, view.exitCode)
}
@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)
}
fun `tool name resolves from payload when message has none`() {
val view =
parseToolOutput(
"""{"tool_id":"call_010","name":"terminal","args":{"command":"sleep 10"}}""",
null,
false,
)
// ── 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)
assertNotNull(view)
assertTrue(view!!.title, view.title.startsWith("Ran "))
assertTrue(view.title.contains("sleep 10"))
}
@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)
fun `running flag drives running status and pending title`() {
val view = parseToolOutput("""{"args":{"command":"sleep 10"}}""", "terminal", true)
assertNotNull(view)
assertEquals(ToolViewStatus.RUNNING, view!!.status)
assertTrue(view.title, view.title.startsWith("Running"))
}
@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)
}
fun `web_search payload yields hits and count`() {
val view =
parseToolOutput(
"""{"args":{"search_term":"cats"},"result":{"data":{"web":[
{"title":"Cat Facts","url":"https://cats.example/1","description":"all about cats"},
{"title":"More Cats","url":"https://cats.example/2","description":"even more"}
]}}}""",
"web_search",
false,
)
// ── 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
}
// ── Diff parsing & Patch tool tests ───────────────────────
@Test
fun testParseDiffText_unifiedDiff() {
val rawDiff =
"""
--- app/src/Main.kt
+++ app/src/Main.kt
@@ -1,3 +1,3 @@
fun main() {
- println("hello")
+ println("hello world")
}
""".trimIndent()
val result = parseDiffText(rawDiff)
assertEquals("app/src/Main.kt", result.filePath)
assertEquals(1, result.additionsCount)
assertEquals(1, result.deletionsCount)
val addedLine = result.lines.find { it.type == DiffLineType.ADDED }
assertNotNull(addedLine)
assertEquals("+ println(\"hello world\")", addedLine!!.text)
assertNotNull(view)
assertEquals(2, view!!.searchHits?.size)
assertEquals("2 results", view.countLabel)
assertEquals("Searched \"cats\"", view.title)
}
@Test
fun testParseToolOutput_patchReplaceMode() {
val json =
"""{
"tool_id": "call_patch_1",
"name": "patch",
"args": {
"path": "app/config.json",
"old_string": "debug=false",
"new_string": "debug=true"
},
"result": {
"output": "--- app/config.json\n+++ app/config.json\n@@ -1,1 +1,1 @@\n-debug=false\n+debug=true"
}
}"""
val parsed = parseToolOutput(json, "patch", false)
assertNotNull(parsed)
assertEquals("app/config.json", parsed!!.diffPath)
val diffOutput = parsed.diffOutput
assertNotNull(diffOutput)
assertTrue(diffOutput!!.contains("-debug=false"))
assertTrue(diffOutput.contains("+debug=true"))
fun `patch payload extracts diff`() {
val view =
parseToolOutput(
"""{"args":{"path":"/repo/src/wiring.tsx"},"result":{"inline_diff":""" +
""""--- a/wiring.tsx\n+++ b/wiring.tsx\n@@ -1,1 +1,1 @@\n-old\n+new"}}""",
"patch",
false,
)
assertNotNull(view)
assertNotNull(view!!.inlineDiff)
assertEquals("/repo/src/wiring.tsx", view.diffPath)
assertEquals(1, view.diffStats?.added)
assertEquals(1, view.diffStats?.removed)
}
@Test
fun testParseToolOutput_editTool() {
val json =
"""{
"tool_id": "call_edit_1",
"name": "edit",
"args": {
"path": "app/src/Main.kt",
"old_string": "val x = 1",
"new_string": "val x = 2"
},
"result": {
"output": "--- app/src/Main.kt\n+++ app/src/Main.kt\n@@ -1,1 +1,1 @@\n-val x = 1\n+val x = 2"
}
}"""
val parsed = parseToolOutput(json, "edit", false)
assertNotNull(parsed)
assertEquals("app/src/Main.kt", parsed!!.diffPath)
val editDiff = parsed.diffOutput
assertNotNull(editDiff)
assertTrue(editDiff!!.contains("-val x = 1"))
assertTrue(editDiff.contains("+val x = 2"))
fun `unknown tool gets an engine summary not raw json`() {
val view =
parseToolOutput(
"""{"result":{"data":{"title":"Build report","completed":true}}}""",
"weird_tool",
false,
)
assertNotNull(view)
assertEquals("Weird Tool", view!!.title)
assertTrue(view.detail, view.detail.contains("Title: Build report"))
assertTrue(view.detail.contains("Completed: true"))
}
@Test
fun testParseSkillManage_createAndPatch() {
val createJson =
"""{
"tool_id": "call_sm_1",
"name": "skill_manage",
"args": {"action": "patch", "name": "our-workflow"},
"result": {"success": true, "message": "Patched skill our-workflow"}
}"""
val parsed = parseToolOutput(createJson, "skill_manage", false)
assertNotNull(parsed)
assertEquals("⚡ Skill Patched: our-workflow", parsed!!.summaryText)
assertEquals("✅ Patched skill our-workflow", parsed.mainOutput)
fun `non-json content returns null`() {
assertNull(parseToolOutput("just plain text", "terminal", false))
}
@Test
fun testParseMemory_selfImprovementBadge() {
val memJson =
"""{
"tool_id": "call_mem_1",
"name": "memory",
"args": {"action": "add", "target": "user"},
"result": {"success": true, "usage": "1,200 / 2,200 chars"}
}"""
val parsed = parseToolOutput(memJson, "memory", false)
assertNotNull(parsed)
assertEquals("🧠 Memory Saved (user)", parsed!!.summaryText)
fun `non-object json returns null`() {
assertNull(parseToolOutput("[1,2,3]", "terminal", false))
}
@Test
fun `memory summary keeps its target`() {
val view =
parseToolOutput("""{"args":{"action":"add","target":"user"},"result":{"success":true}}""", "memory", false)
assertNotNull(view)
assertEquals("Memory Saved (user)", view!!.title)
}
}
@@ -0,0 +1,93 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.tool.ToolView
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Contract tests for [composeToolSummaryLines] — the collapsed-summary
* deduplication: the header row already shows the tool name, so a generic
* engine title ("Fact Store") must not appear a second time in the summary.
*/
class ToolSummaryLinesTest {
private fun view(
title: String,
subtitle: String = "",
countLabel: String? = null,
) = ToolView(
status = ToolViewStatus.SUCCESS,
title = title,
subtitle = subtitle,
countLabel = countLabel,
)
@Test
fun `generic title merges into the emoji line`() {
val lines = composeToolSummaryLines(view("Fact Store", "Fact added (ID: 5)"), "fact_store", "🧠")
assertEquals("🧠 Fact added (ID: 5)", lines!!.first)
assertNull(lines.second)
}
@Test
fun `todo shows counts on one line`() {
val lines = composeToolSummaryLines(view("Todo", "2 items (1 pending, 1 completed)"), "todo", "✅")
assertEquals("✅ 2 items (1 pending, 1 completed)", lines!!.first)
assertNull(lines.second)
}
@Test
fun `descriptive title stays with subtitle on line two`() {
val lines = composeToolSummaryLines(view("Searched \"cats\"", "Query: cats", "3 results"), "web_search", "🌐")
assertEquals("🌐 Searched \"cats\" (3 results)", lines!!.first)
assertEquals("Query: cats", lines.second)
}
@Test
fun `terminal title stays with output preview on line two`() {
val lines = composeToolSummaryLines(view("Ran ls", "wiring.tsx"), "terminal", "💻")
assertEquals("💻 Ran ls", lines!!.first)
assertEquals("wiring.tsx", lines.second)
}
@Test
fun `read file title stays with path on line two`() {
val lines = composeToolSummaryLines(view("Read a.kt L10-14", "/repo/src/a.kt"), "read_file", "📄")
assertEquals("📄 Read a.kt L10-14", lines!!.first)
assertEquals("/repo/src/a.kt", lines.second)
}
@Test
fun `generic title with no subtitle and no count is hidden`() {
assertNull(composeToolSummaryLines(view("Fact Store"), "fact_store", "🧠"))
}
@Test
fun `unknown tool generic title merges with engine first line`() {
val lines = composeToolSummaryLines(view("Weird Tool", "- Title: Build report"), "weird_tool", "🔧")
assertEquals("🔧 - Title: Build report", lines!!.first)
assertNull(lines.second)
}
@Test
fun `null tool name resolves to the generic tool title`() {
val lines = composeToolSummaryLines(view("Tool", "raw payload text"), null, "🔧")
assertEquals("🔧 raw payload text", lines!!.first)
assertNull(lines.second)
}
@Test
fun `subtitle identical to title is not duplicated`() {
val lines = composeToolSummaryLines(view("Fact Store", "Fact Store"), "fact_store", "🧠")
assertNull(lines)
}
}