From cc010b451ae566e63f9575ef6aeaa0bf83a9f22d Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:39:17 +0300 Subject: [PATCH] refactor(chat): unify tool catalog and prewarm payloads (#1326, #1327) (#1356) --- .../hermescontrol/ui/chat/ChatViewModel.kt | 22 ++ .../m57/hermescontrol/ui/chat/ToolBubble.kt | 3 +- .../ui/chat/ToolSchemaRegistry.kt | 281 +----------------- .../hermescontrol/ui/chat/tool/ToolCatalog.kt | 144 +++++++++ .../ui/chat/tool/ToolRendererRegistry.kt | 107 +------ .../ui/chat/tool/ToolViewCache.kt | 61 ++++ .../ui/chat/tool/ToolCatalogTest.kt | 53 ++++ .../ui/chat/tool/ToolViewCacheTest.kt | 51 ++++ 8 files changed, 339 insertions(+), 383 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalog.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCache.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalogTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCacheTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 465e4356..8a0d9c8e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -40,6 +40,7 @@ import com.m57.hermescontrol.data.ws.toAny import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.notification.captureTurnBoundary import com.m57.hermescontrol.notification.correlationScopeId +import com.m57.hermescontrol.ui.chat.tool.ToolViewCache import com.m57.hermescontrol.ui.common.ActionProgressController import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -51,6 +52,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -734,6 +738,24 @@ class ChatViewModel( _uiState.value, ) + init { + // Parse tool payloads off the main thread before the transcript + // composes them (issue #1327); ToolBubble then reads a cache hit. + viewModelScope.launch(searchDispatcher) { + _uiState + .map { it.messages } + .distinctUntilChanged() + .conflate() + .collect { messages -> + for (message in messages) { + if (message.role == MessageRole.TOOL) { + ToolViewCache.prewarm(message.content, message.toolName, message.isToolRunning) + } + } + } + } + } + /** * Session ID to resume when the WebSocket connects. Set synchronously by * [ChatScreen] via `SideEffect` during composition — before any WS event diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt index 54946d42..67901cdf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -73,6 +73,7 @@ import com.m57.hermescontrol.ui.chat.components.DiffViewCard import com.m57.hermescontrol.ui.chat.components.FileViewCard import com.m57.hermescontrol.ui.chat.tool.ToolView import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder +import com.m57.hermescontrol.ui.chat.tool.ToolViewCache import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -133,7 +134,7 @@ internal fun ToolBubble( val view = remember(message.content, message.toolName, message.isToolRunning) { - parseToolOutput(message.content, message.toolName, message.isToolRunning) + ToolViewCache.getOrParse(message.content, message.toolName, message.isToolRunning) } val config = ToolSchemaRegistry.getDisplayConfig(message.toolName) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt index 22afc91d..7a61217c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt @@ -1,292 +1,19 @@ package com.m57.hermescontrol.ui.chat import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.MenuBook -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.AccountTree -import androidx.compose.material.icons.filled.AutoStories import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.ChatBubble -import androidx.compose.material.icons.filled.Checklist -import androidx.compose.material.icons.filled.Computer -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Forum -import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.Keyboard -import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.Memory -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.Photo -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Psychology -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Terminal -import androidx.compose.material.icons.filled.ThumbUp -import androidx.compose.material.icons.filled.TouchApp -import androidx.compose.material.icons.filled.Visibility import androidx.compose.ui.graphics.vector.ImageVector +import com.m57.hermescontrol.ui.chat.tool.ToolCatalog -/** - * Display configuration for a Hermes tool — controls the summary line, - * icon, and which arg field is shown when the bubble is collapsed. - * - * Based on the canonical tool schemas defined in the Hermes Agent source - * at /opt/hermes-agent/tools/ (each tool has a `*_SCHEMA` dict). - */ +/** Display configuration for a Hermes tool — its name and header icon. */ data class ToolDisplayConfig( val name: String, /** Material icon for this tool type. */ val icon: ImageVector = Icons.Filled.Build, ) -/** Maps tool names to their display config. Ordered by expected frequency of use. */ +/** Icon lookup backed by the single [ToolCatalog] (issue #1326). */ object ToolSchemaRegistry { - private val knownTools: Map = - mapOf( - "terminal" to - ToolDisplayConfig( - name = "terminal", - icon = Icons.Filled.Terminal, - ), - "read_file" to - ToolDisplayConfig( - name = "read_file", - icon = Icons.Filled.Description, - ), - "write_file" to - ToolDisplayConfig( - name = "write_file", - icon = Icons.Filled.Edit, - ), - "patch" to - ToolDisplayConfig( - name = "patch", - icon = Icons.Filled.Build, - ), - "search_files" to - ToolDisplayConfig( - name = "search_files", - icon = Icons.Filled.Search, - ), - "web_search" to - ToolDisplayConfig( - name = "web_search", - icon = Icons.Filled.Public, - ), - "browser_navigate" to - ToolDisplayConfig( - name = "browser_navigate", - icon = Icons.Filled.Language, - ), - "browser_click" to - ToolDisplayConfig( - name = "browser_click", - icon = Icons.Filled.TouchApp, - ), - "browser_snapshot" to - ToolDisplayConfig( - name = "browser_snapshot", - icon = Icons.Filled.Photo, - ), - "clarify" to - ToolDisplayConfig( - name = "clarify", - icon = Icons.Filled.ChatBubble, - ), - "delegate_task" to - ToolDisplayConfig( - name = "delegate_task", - icon = Icons.Filled.AccountTree, - ), - "execute_code" to - ToolDisplayConfig( - name = "execute_code", - icon = Icons.Filled.PlayArrow, - ), - "todo" to - ToolDisplayConfig( - name = "todo", - icon = Icons.Filled.Checklist, - ), - "todo_list" to - ToolDisplayConfig( - name = "todo_list", - icon = Icons.Filled.Checklist, - ), - "fact_store" to - ToolDisplayConfig( - name = "fact_store", - icon = Icons.Filled.Psychology, - ), - "session_search" to - ToolDisplayConfig( - name = "session_search", - icon = Icons.Filled.Search, - ), - // ── Action-based tools ── - "cronjob" to - ToolDisplayConfig( - name = "cronjob", - icon = Icons.Filled.Schedule, - ), - "cronjob_manage" to - ToolDisplayConfig( - name = "cronjob_manage", - icon = Icons.Filled.Schedule, - ), - "memory" to - ToolDisplayConfig( - name = "memory", - icon = Icons.Filled.Memory, - ), - "fact_feedback" to - ToolDisplayConfig( - name = "fact_feedback", - icon = Icons.Filled.ThumbUp, - ), - "process" to - ToolDisplayConfig( - name = "process", - icon = Icons.Filled.Settings, - ), - "process_manage" to - ToolDisplayConfig( - name = "process_manage", - icon = Icons.Filled.Settings, - ), - "skill_manage" to - ToolDisplayConfig( - name = "skill_manage", - icon = Icons.Filled.Build, - ), - // ── Web / Browser tools ── - "web_extract" to - ToolDisplayConfig( - name = "web_extract", - icon = Icons.Filled.Language, - ), - "browser_type" to - ToolDisplayConfig( - name = "browser_type", - icon = Icons.Filled.Keyboard, - ), - "browser_scroll" to - ToolDisplayConfig( - name = "browser_scroll", - icon = Icons.Filled.TouchApp, - ), - "browser_back" to - ToolDisplayConfig( - name = "browser_back", - icon = Icons.Filled.Language, - ), - "browser_press" to - ToolDisplayConfig( - name = "browser_press", - icon = Icons.Filled.Keyboard, - ), - "browser_get_images" to - ToolDisplayConfig( - name = "browser_get_images", - icon = Icons.Filled.Image, - ), - "browser_vision" to - ToolDisplayConfig( - name = "browser_vision", - icon = Icons.Filled.Visibility, - ), - "browser_console" to - ToolDisplayConfig( - name = "browser_console", - icon = Icons.Filled.Terminal, - ), - "browser_cdp" to - ToolDisplayConfig( - name = "browser_cdp", - icon = Icons.Filled.Build, - ), - "browser_dialog" to - ToolDisplayConfig( - name = "browser_dialog", - icon = Icons.Filled.ChatBubble, - ), - // ── Media / Vision tools ── - "vision_analyze" to - ToolDisplayConfig( - name = "vision_analyze", - icon = Icons.Filled.Visibility, - ), - "text_to_speech" to - ToolDisplayConfig( - name = "text_to_speech", - icon = Icons.AutoMirrored.Filled.VolumeUp, - ), - "video_generate" to - ToolDisplayConfig( - name = "video_generate", - icon = Icons.Filled.Movie, - ), - "image_generate" to - ToolDisplayConfig( - name = "image_generate", - icon = Icons.Filled.Image, - ), - // ── Social / Messaging tools ── - "x_search" to - ToolDisplayConfig( - name = "x_search", - icon = Icons.Filled.Forum, - ), - "send_message" to - ToolDisplayConfig( - name = "send_message", - icon = Icons.AutoMirrored.Filled.Send, - ), - // ── Skills tools ── - "skills_list" to - ToolDisplayConfig( - name = "skills_list", - icon = Icons.AutoMirrored.Filled.MenuBook, - ), - "skill_view" to - ToolDisplayConfig( - name = "skill_view", - icon = Icons.Filled.AutoStories, - ), - // ── On-demand tools ── - "tool_search" to - ToolDisplayConfig( - name = "tool_search", - icon = Icons.Filled.Search, - ), - "tool_describe" to - ToolDisplayConfig( - name = "tool_describe", - icon = Icons.Filled.AutoStories, - ), - "tool_call" to - ToolDisplayConfig( - name = "tool_call", - icon = Icons.Filled.Build, - ), - // ── Misc ── - "read_terminal" to - ToolDisplayConfig( - name = "read_terminal", - icon = Icons.Filled.Computer, - ), - "computer_use" to - ToolDisplayConfig( - name = "computer_use", - icon = Icons.Filled.Computer, - ), - ) - fun getDisplayConfig(toolName: String?): ToolDisplayConfig = - toolName?.let { knownTools[it] } ?: ToolDisplayConfig(name = toolName ?: "tool") + ToolDisplayConfig(name = toolName ?: "tool", icon = ToolCatalog.definitionFor(toolName).icon) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalog.kt new file mode 100644 index 00000000..2e6e25f0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalog.kt @@ -0,0 +1,144 @@ +package com.m57.hermescontrol.ui.chat.tool + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.AutoStories +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.Movie +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.ui.graphics.vector.ImageVector +import com.m57.hermescontrol.ui.chat.tool.render.BrowserActionRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserConsoleRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserImagesRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserNavigateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserVisionRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ComputerUseRenderer +import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer +import com.m57.hermescontrol.ui.chat.tool.render.DelegateTaskRenderer +import com.m57.hermescontrol.ui.chat.tool.render.FactStoreRenderer +import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ImageGenerateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.MemoryRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProcessRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SearchFilesRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SessionSearchRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SkillViewRenderer +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 +import com.m57.hermescontrol.ui.chat.tool.render.WebSearchRenderer +import com.m57.hermescontrol.ui.chat.tool.render.XSearchRenderer + +/** A tool's visual identity (header/status icon) and its [ToolRenderer]. */ +internal data class ToolDefinition( + val icon: ImageVector, + val renderer: ToolRenderer, +) + +/** All-default renderer: defers everything to the generic fallbacks. */ +internal object GenericToolRenderer : ToolRenderer + +/** + * The single tool catalog (issue #1326): every known tool declares its icon + * and renderer together, so the two can no longer drift apart. + * + * Unknown tools get the Build icon and [GenericToolRenderer]: every hook + * returns null, so [ToolViewBuilder] falls back to the humanized tool name + * and the [ToolResultSummary] heuristics — never a raw JSON dump. + */ +internal object ToolCatalog { + val FALLBACK = ToolDefinition(Icons.Filled.Build, GenericToolRenderer) + + val entries: Map = + mapOf( + "browser_back" to ToolDefinition(Icons.Filled.Language, BrowserActionRenderer), + "browser_cdp" to ToolDefinition(Icons.Filled.Build, GenericToolRenderer), + "browser_click" to ToolDefinition(Icons.Filled.TouchApp, BrowserClickRenderer), + "browser_console" to ToolDefinition(Icons.Filled.Terminal, BrowserConsoleRenderer), + "browser_dialog" to ToolDefinition(Icons.Filled.ChatBubble, GenericToolRenderer), + "browser_fill" to ToolDefinition(Icons.Filled.Keyboard, BrowserTypeRenderer), + "browser_get_images" to ToolDefinition(Icons.Filled.Image, BrowserImagesRenderer), + "browser_navigate" to ToolDefinition(Icons.Filled.Language, BrowserNavigateRenderer), + "browser_press" to ToolDefinition(Icons.Filled.Keyboard, BrowserActionRenderer), + "browser_scroll" to ToolDefinition(Icons.Filled.TouchApp, BrowserActionRenderer), + "browser_snapshot" to ToolDefinition(Icons.Filled.Photo, BrowserSnapshotRenderer), + "browser_type" to ToolDefinition(Icons.Filled.Keyboard, BrowserTypeRenderer), + "browser_vision" to ToolDefinition(Icons.Filled.Visibility, BrowserVisionRenderer), + "clarify" to ToolDefinition(Icons.Filled.ChatBubble, GenericToolRenderer), + "computer_use" to ToolDefinition(Icons.Filled.Computer, ComputerUseRenderer), + "cronjob" to ToolDefinition(Icons.Filled.Schedule, CronjobRenderer), + "cronjob_manage" to ToolDefinition(Icons.Filled.Schedule, CronjobRenderer), + "delegate_task" to ToolDefinition(Icons.Filled.AccountTree, DelegateTaskRenderer), + "edit_file" to ToolDefinition(Icons.Filled.Edit, FileEditRenderer), + "execute_code" to ToolDefinition(Icons.Filled.PlayArrow, TerminalRenderer), + "fact_feedback" to ToolDefinition(Icons.Filled.ThumbUp, GenericToolRenderer), + "fact_store" to ToolDefinition(Icons.Filled.Psychology, FactStoreRenderer), + "image_generate" to ToolDefinition(Icons.Filled.Image, ImageGenerateRenderer), + "memory" to ToolDefinition(Icons.Filled.Memory, MemoryRenderer), + "patch" to ToolDefinition(Icons.Filled.Build, FileEditRenderer), + "process" to ToolDefinition(Icons.Filled.Settings, ProcessRenderer), + "process_manage" to ToolDefinition(Icons.Filled.Settings, ProcessRenderer), + "project_create" to ToolDefinition(Icons.Filled.AccountTree, ProjectMutateRenderer), + "project_list" to ToolDefinition(Icons.Filled.AccountTree, ProjectListRenderer), + "project_switch" to ToolDefinition(Icons.Filled.AccountTree, ProjectMutateRenderer), + "read_file" to ToolDefinition(Icons.Filled.Description, ReadFileRenderer), + "read_terminal" to ToolDefinition(Icons.Filled.Computer, ReadTerminalRenderer), + "search_files" to ToolDefinition(Icons.Filled.Search, SearchFilesRenderer), + "send_message" to ToolDefinition(Icons.AutoMirrored.Filled.Send, GenericToolRenderer), + "session_search" to ToolDefinition(Icons.Filled.Search, SessionSearchRenderer), + "skill_manage" to ToolDefinition(Icons.Filled.Build, SkillManageRenderer), + "skill_view" to ToolDefinition(Icons.Filled.AutoStories, SkillViewRenderer), + "skills_list" to ToolDefinition(Icons.AutoMirrored.Filled.MenuBook, SkillsListRenderer), + "terminal" to ToolDefinition(Icons.Filled.Terminal, TerminalRenderer), + "text_to_speech" to ToolDefinition(Icons.AutoMirrored.Filled.VolumeUp, TextToSpeechRenderer), + "todo" to ToolDefinition(Icons.Filled.Checklist, TodoRenderer), + "todo_list" to ToolDefinition(Icons.Filled.Checklist, TodoRenderer), + "tool_call" to ToolDefinition(Icons.Filled.Build, GenericToolRenderer), + "tool_describe" to ToolDefinition(Icons.Filled.AutoStories, ToolDescribeRenderer), + "tool_search" to ToolDefinition(Icons.Filled.Search, ToolSearchRenderer), + "video_generate" to ToolDefinition(Icons.Filled.Movie, GenericToolRenderer), + "vision_analyze" to ToolDefinition(Icons.Filled.Visibility, VisionAnalyzeRenderer), + "web_extract" to ToolDefinition(Icons.Filled.Language, WebExtractRenderer), + "web_search" to ToolDefinition(Icons.Filled.Public, WebSearchRenderer), + "write_file" to ToolDefinition(Icons.Filled.Edit, FileEditRenderer), + "x_search" to ToolDefinition(Icons.Filled.Forum, XSearchRenderer), + ) + + fun definitionFor(toolName: String?): ToolDefinition = toolName?.let { entries[it] } ?: FALLBACK +} 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 1d06f7f1..9e443595 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 @@ -1,109 +1,6 @@ package com.m57.hermescontrol.ui.chat.tool -import com.m57.hermescontrol.ui.chat.tool.render.BrowserActionRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserConsoleRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserImagesRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserNavigateRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserVisionRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ComputerUseRenderer -import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer -import com.m57.hermescontrol.ui.chat.tool.render.DelegateTaskRenderer -import com.m57.hermescontrol.ui.chat.tool.render.FactStoreRenderer -import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ImageGenerateRenderer -import com.m57.hermescontrol.ui.chat.tool.render.MemoryRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ProcessRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer -import com.m57.hermescontrol.ui.chat.tool.render.SearchFilesRenderer -import com.m57.hermescontrol.ui.chat.tool.render.SessionSearchRenderer -import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer -import com.m57.hermescontrol.ui.chat.tool.render.SkillViewRenderer -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 -import com.m57.hermescontrol.ui.chat.tool.render.WebSearchRenderer -import com.m57.hermescontrol.ui.chat.tool.render.XSearchRenderer - -/** - * Maps tool names to their display strategy. - * - * Unknown tools get [GenericToolRenderer]: every hook returns null, so - * [ToolViewBuilder] falls back to the humanized tool name and the - * [ToolResultSummary] heuristics — never a raw JSON dump. - */ +/** Renderer lookup backed by the single [ToolCatalog]. */ internal object ToolRendererRegistry { - /** All-default renderer: defers everything to the generic fallbacks. */ - private object GenericToolRenderer : ToolRenderer - - private val renderers: Map = - mapOf( - // Shell - "terminal" to TerminalRenderer, - "execute_code" to TerminalRenderer, - "read_terminal" to ReadTerminalRenderer, - // Delegation - "delegate_task" to DelegateTaskRenderer, - // Files - "read_file" to ReadFileRenderer, - "edit_file" to FileEditRenderer, - "patch" to FileEditRenderer, - "write_file" to FileEditRenderer, - "search_files" to SearchFilesRenderer, - // Web - "web_search" to WebSearchRenderer, - "web_extract" to WebExtractRenderer, - "x_search" to XSearchRenderer, - // Browser - "browser_navigate" to BrowserNavigateRenderer, - "browser_snapshot" to BrowserSnapshotRenderer, - "browser_click" to BrowserClickRenderer, - "browser_fill" to BrowserTypeRenderer, - "browser_type" to BrowserTypeRenderer, - "browser_scroll" to BrowserActionRenderer, - "browser_back" to BrowserActionRenderer, - "browser_press" to BrowserActionRenderer, - "browser_get_images" to BrowserImagesRenderer, - "browser_vision" to BrowserVisionRenderer, - "browser_console" to BrowserConsoleRenderer, - // Memory / facts - "memory" to MemoryRenderer, - "fact_store" to FactStoreRenderer, - // Task management - "cronjob" to CronjobRenderer, - "cronjob_manage" to CronjobRenderer, - "todo" to TodoRenderer, - "todo_list" to TodoRenderer, - "session_search" to SessionSearchRenderer, - "process" to ProcessRenderer, - "process_manage" to ProcessRenderer, - // Skills / tool discovery - "skills_list" to SkillsListRenderer, - "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, - "text_to_speech" to TextToSpeechRenderer, - // Projects - "project_list" to ProjectListRenderer, - "project_create" to ProjectMutateRenderer, - "project_switch" to ProjectMutateRenderer, - // Desktop automation - "computer_use" to ComputerUseRenderer, - ) - - fun rendererFor(toolName: String): ToolRenderer = renderers[toolName] ?: GenericToolRenderer + fun rendererFor(toolName: String): ToolRenderer = ToolCatalog.definitionFor(toolName).renderer } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCache.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCache.kt new file mode 100644 index 00000000..4b000a9d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCache.kt @@ -0,0 +1,61 @@ +package com.m57.hermescontrol.ui.chat.tool + +import com.m57.hermescontrol.ui.chat.parseToolOutput + +/** + * Bounded memo of parsed tool payloads (issue #1327). + * + * The ViewModel [prewarm]s tool rows on a background dispatcher whenever the + * transcript changes, so [ToolBubble] normally gets a cache hit during + * composition instead of parsing JSON on the main thread. A miss (e.g. a live + * event that lands before prewarm finishes) parses synchronously exactly like + * before, so the rendered result — and its height — never changes. + * + * Keyed by the payload itself (not its hash), so a hit is always the view the + * same inputs would produce. + */ +internal object ToolViewCache { + private const val MAX_ENTRIES = 512 + + private data class Key( + val content: String, + val toolName: String?, + val isRunning: Boolean, + ) + + /** Boxes the result so a null parse (non-JSON content) is cached too. */ + private class Entry( + val view: ToolView?, + ) + + private val entries = + object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > MAX_ENTRIES + } + + fun getOrParse( + content: String, + toolName: String?, + isRunning: Boolean, + ): ToolView? { + val key = Key(content, toolName, isRunning) + synchronized(entries) { entries[key] }?.let { return it.view } + val view = parseToolOutput(content, toolName, isRunning) + synchronized(entries) { entries[key] = Entry(view) } + return view + } + + fun prewarm( + content: String, + toolName: String?, + isRunning: Boolean, + ) { + getOrParse(content, toolName, isRunning) + } + + internal fun size(): Int = synchronized(entries) { entries.size } + + internal fun clear() { + synchronized(entries) { entries.clear() } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalogTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalogTest.kt new file mode 100644 index 00000000..ad2af493 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalogTest.kt @@ -0,0 +1,53 @@ +package com.m57.hermescontrol.ui.chat.tool + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Terminal +import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry +import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer +import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class ToolCatalogTest { + @Test + fun `icon and renderer lookups read the same catalog entry`() { + for ((name, def) in ToolCatalog.entries) { + assertSame(name, def.icon, ToolSchemaRegistry.getDisplayConfig(name).icon) + assertSame(name, def.renderer, ToolRendererRegistry.rendererFor(name)) + assertEquals(name, ToolSchemaRegistry.getDisplayConfig(name).name) + } + } + + @Test + fun `tools that drifted now have a real icon`() { + assertSame(Icons.Filled.Edit, ToolSchemaRegistry.getDisplayConfig("edit_file").icon) + assertSame(Icons.Filled.Keyboard, ToolSchemaRegistry.getDisplayConfig("browser_fill").icon) + for (name in listOf("project_list", "project_create", "project_switch")) { + assertSame(name, Icons.Filled.AccountTree, ToolSchemaRegistry.getDisplayConfig(name).icon) + } + } + + @Test + fun `known tools keep their renderers and icons`() { + assertSame(TerminalRenderer, ToolRendererRegistry.rendererFor("terminal")) + assertSame(Icons.Filled.Terminal, ToolSchemaRegistry.getDisplayConfig("terminal").icon) + assertSame(FileEditRenderer, ToolRendererRegistry.rendererFor("edit_file")) + assertSame(BrowserTypeRenderer, ToolRendererRegistry.rendererFor("browser_fill")) + // Icon-only tools still render generically. + assertSame(GenericToolRenderer, ToolRendererRegistry.rendererFor("clarify")) + } + + @Test + fun `unknown tools fall back to build icon and generic renderer`() { + assertSame(Icons.Filled.Build, ToolSchemaRegistry.getDisplayConfig("mystery").icon) + assertEquals("mystery", ToolSchemaRegistry.getDisplayConfig("mystery").name) + assertEquals("tool", ToolSchemaRegistry.getDisplayConfig(null).name) + assertSame(GenericToolRenderer, ToolRendererRegistry.rendererFor("mystery")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCacheTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCacheTest.kt new file mode 100644 index 00000000..14163bc1 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCacheTest.kt @@ -0,0 +1,51 @@ +package com.m57.hermescontrol.ui.chat.tool + +import com.m57.hermescontrol.ui.chat.parseToolOutput +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test + +class ToolViewCacheTest { + private val payload = """{"name":"terminal","args":{"command":"ls"},"result":{"output":"a","exit_code":0}}""" + + @Before + fun reset() = ToolViewCache.clear() + + @Test + fun `cached view equals a fresh parse`() { + val cached = ToolViewCache.getOrParse(payload, "terminal", false) + assertEquals(parseToolOutput(payload, "terminal", false), cached) + } + + @Test + fun `prewarm makes composition read a hit`() { + ToolViewCache.prewarm(payload, "terminal", false) + val first = ToolViewCache.getOrParse(payload, "terminal", false) + assertSame(first, ToolViewCache.getOrParse(payload, "terminal", false)) + assertEquals(1, ToolViewCache.size()) + } + + @Test + fun `running state and content changes are separate entries`() { + val running = ToolViewCache.getOrParse(payload, "terminal", true) + val done = ToolViewCache.getOrParse(payload, "terminal", false) + assertNotSame(running, done) + assertEquals(2, ToolViewCache.size()) + } + + @Test + fun `non json content is cached as null`() { + assertNull(ToolViewCache.getOrParse("plain text", "terminal", false)) + assertNull(ToolViewCache.getOrParse("plain text", "terminal", false)) + assertEquals(1, ToolViewCache.size()) + } + + @Test + fun `cache is bounded`() { + repeat(600) { i -> ToolViewCache.getOrParse("""{"i":$i}""", "terminal", false) } + assertEquals(512, ToolViewCache.size()) + } +}