diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ToolDiscoveryBubbleTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ToolDiscoveryBubbleTest.kt new file mode 100644 index 00000000..77b638ff --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ToolDiscoveryBubbleTest.kt @@ -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, + ), + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt index 3df58405..1d06f7f1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt index 0937962c..758945f3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt @@ -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() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt index 8e05020d..12e04846 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt @@ -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 =