fix: show tool discovery details in chat bubbles (#1237)

This commit is contained in:
m57 authored and GitHub committed 2026-09-23 12:58:21 +03:00
1 parent 1270a08810
commit 0eb8d9a8c1
4 files changed
+177 -13

No files matched your search

@@ -0,0 +1,57 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.theme.HermesControlTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class ToolDiscoveryBubbleTest {
@get:Rule val composeRule = createComposeRule()
@Test
fun tappingSearchBubbleShowsMatchedToolDetails() {
showBubble(
"tool_search",
"""{"name":"tool_search","args":{"queries":["process output"]},"result":{"results":[{"query":"process output","matches":["process_manage"]}],"tools":{"process_manage":{"description":"Poll a background process","source_name":"terminal","required":["action"]}}}}""",
)
composeRule.onNodeWithText("process output (1 match)", substring = true).performClick()
composeRule.onNodeWithText("Poll a background process", substring = true).assertExists()
}
@Test
fun tappingDescribeBubbleShowsSchema() {
showBubble(
"tool_describe",
"""{"name":"tool_describe","args":{"names":["process_manage"]},"result":{"tools":{"process_manage":{"description":"Poll a background process","parameters":{"type":"object","properties":{"action":{"type":"string","description":"Action to take"}},"required":["action"]}}}}}""",
)
composeRule.onNodeWithText("process_manage (1 tool)", substring = true).performClick()
composeRule.onNodeWithText("Action to take", substring = true).assertExists()
}
private fun showBubble(
name: String,
content: String,
) {
composeRule.setContent {
HermesControlTheme {
ToolBubble(
ChatMessage(
role = MessageRole.TOOL,
content = content,
toolName = name,
toolStatus = ToolStatus.COMPLETED,
),
)
}
}
}
}
@@ -28,6 +28,7 @@ import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer
import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer
import com.m57.hermescontrol.ui.chat.tool.render.TextToSpeechRenderer
import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ToolDescribeRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer
import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer
import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer
@@ -91,6 +92,7 @@ internal object ToolRendererRegistry {
"skill_view" to SkillViewRenderer,
"skill_manage" to SkillManageRenderer,
"tool_search" to ToolSearchRenderer,
"tool_describe" to ToolDescribeRenderer,
// Media
"image_generate" to ImageGenerateRenderer,
"vision_analyze" to VisionAnalyzeRenderer,
@@ -118,21 +118,91 @@ internal object SkillManageRenderer : ToolRenderer {
/** `tool_search`: on-demand tool discovery matches. */
internal object ToolSearchRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val query = ToolJson.firstString(call.args, listOf("query"))
val matches = (call.result?.get("matches") as? JsonArray)?.size
val query =
ToolJson
.firstString(call.args, listOf("query"))
.ifEmpty { (call.args?.get("queries") as? JsonArray)?.joinToString(", ") { text(it) } ?: "" }
val matches =
(call.result?.get("results") as? JsonArray)?.sumOf { row ->
(ToolJson.parseMaybeObject(row)?.get("matches") as? JsonArray)?.size ?: 0
} ?: (call.result?.get("matches") as? JsonArray)?.size
return if (matches != null) "$query ($matches matches)" else query
return if (matches != null) "$query ($matches ${if (matches == 1) "match" else "matches"})" else query
}
override fun detail(call: ToolCall): String {
val tools = call.result?.get("tools") as? JsonObject
val matches =
(call.result?.get("results") as? JsonArray)
?.flatMap { row -> (ToolJson.parseMaybeObject(row)?.get("matches") as? JsonArray).orEmpty() }
?: (call.result?.get("matches") as? JsonArray).orEmpty()
return matches
.mapNotNull { match ->
val item = ToolJson.parseMaybeObject(match)
val name = item?.let { ToolJson.firstString(it, listOf("name")) } ?: text(match)
if (name.isEmpty()) return@mapNotNull null
val metadata = tools?.get(name) as? JsonObject
val desc =
ToolJson
.firstString(metadata, listOf("description"))
.ifEmpty { ToolJson.firstString(item, listOf("description")) }
val source = ToolJson.firstString(metadata, listOf("source_name"))
val required = (metadata?.get("required") as? JsonArray)?.joinToString(", ") { text(it) }.orEmpty()
buildString {
append("🔧 $name")
if (source.isNotEmpty()) append(" · $source")
if (desc.isNotEmpty()) append("\n $desc")
if (required.isNotEmpty()) append("\n Required: $required")
}
}.distinct()
.joinToString("\n\n")
.ifEmpty { "No matching tools" }
}
}
/** `tool_describe`: readable schema summaries instead of a collapsed JSON object. */
internal object ToolDescribeRenderer : ToolRenderer {
override fun subtitle(call: ToolCall): String {
val names = (call.args?.get("names") as? JsonArray)?.joinToString(", ") { text(it) }.orEmpty()
val count = (call.result?.get("tools") as? JsonObject)?.size
return if (count != null) "$names ($count ${if (count == 1) "tool" else "tools"})" else names
}
override fun detail(call: ToolCall): String =
(call.result?.get("matches") as? JsonArray)
?.mapNotNull { el ->
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val name = ToolJson.firstString(m, listOf("name"))
val desc = ToolJson.firstString(m, listOf("description"))
val lines = mutableListOf("🔧 $name")
if (desc.isNotEmpty()) lines += " $desc"
lines.joinToString("\n")
}?.joinToString("\n\n")
?: "No matching tools"
(call.result?.get("tools") as? JsonObject)
?.entries
?.joinToString("\n\n") { (name, value) ->
val tool = ToolJson.parseMaybeObject(value)
val desc = ToolJson.firstString(tool, listOf("description"))
val schema = tool?.get("parameters") as? JsonObject
val properties = schema?.get("properties") as? JsonObject
val required = (schema?.get("required") as? JsonArray)?.map(::text).orEmpty().toSet()
buildString {
append("🔧 $name")
if (desc.isNotEmpty()) append("\n $desc")
if (!properties.isNullOrEmpty()) {
append("\n Parameters: ")
append(
properties.entries.joinToString("; ") { (paramName, value) ->
val param = ToolJson.parseMaybeObject(value)
val type = ToolJson.firstString(param, listOf("type"))
val help = ToolJson.firstString(param, listOf("description"))
val default = param?.get("default")
buildString {
append(paramName)
if (type.isNotEmpty()) append(" ($type)")
if (paramName in required) append(" *")
if (help.isNotEmpty()) append(" — $help")
if (default != null) append(" [default: ${text(default)}]")
}
},
)
} else if (required.isNotEmpty()) {
append("\n Required: ${required.joinToString(", ")}")
}
}
}?.ifEmpty { "No tool descriptions returned" } ?: "No tool descriptions returned"
}
private fun text(value: kotlinx.serialization.json.JsonElement): String =
(value as? JsonPrimitive)?.content ?: value.toString()
@@ -758,6 +758,41 @@ class ToolViewCharacterizationTest {
assertEquals("🔧 read_file\n Reads\n\n🔧 write_file", view.detail)
}
@Test
fun `tool_search renders queries and named tool metadata from discovery response`() {
val view =
build(
"tool_search",
"""{"queries":["read background process output poll"]}""",
"""{"queries":["read background process output poll"],"total_available":119,"results":[{"query":"read background process output poll","matches":["process_manage","close_terminal"]}],"tools":{"process_manage":{"source":"plugin","source_name":"terminal","description":"Poll or wait on a process","required":["action"]},"close_terminal":{"source":"plugin","source_name":"desktop_ui","description":"Hide a terminal tab","required":["process_id"]}}}""",
)
assertEquals("read background process output poll (2 matches)", view.subtitle)
assertEquals(
"🔧 process_manage · terminal\n Poll or wait on a process\n Required: action\n\n" +
"🔧 close_terminal · desktop_ui\n Hide a terminal tab\n Required: process_id",
view.detail,
)
}
@Test
fun `tool_describe renders tool parameters and required fields`() {
val view =
build(
"tool_describe",
"""{"names":["mcp__agentmail__list_inboxes"]}""",
"""{"tools":{"mcp__agentmail__list_inboxes":{"description":"List email inboxes, paginated.","parameters":{"type":"object","properties":{"limit":{"type":"number","description":"Max number of items to return","default":10},"pageToken":{"type":"string","description":"Page token for pagination"}},"required":[]}}}}""",
)
assertEquals("mcp__agentmail__list_inboxes (1 tool)", view.subtitle)
assertEquals(
"🔧 mcp__agentmail__list_inboxes\n List email inboxes, paginated.\n" +
" Parameters: limit (number) — Max number of items to return [default: 10]; " +
"pageToken (string) — Page token for pagination",
view.detail,
)
}
@Test
fun `read_terminal shows line window and terminal block`() {
val view =