fix: show tool discovery details in chat bubbles (#1237)
This commit is contained in:
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 =
|
||||
|
||||
Reference in new issue
Block a user