diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ParsedToolData.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ParsedToolData.kt deleted file mode 100644 index 6635b064..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ParsedToolData.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -/** - * Cleanly parsed representation of a tool call, extracted from the - * tool.complete JSON payload (which contains both args and result). - */ -data class ParsedToolData( - val toolName: String = "", - val isTerminal: Boolean = false, - val stdout: String? = null, - val exitCode: Int? = null, - val error: String? = null, - val summaryText: String? = null, - val durationSec: Double? = null, - val mainOutput: String? = null, - val extraFields: Map = emptyMap(), - val isRunning: Boolean = false, - val diffOutput: String? = null, - val diffPath: String? = null, -) 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 17eb128d..9ae35e17 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 @@ -60,132 +60,52 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.HermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.chat.components.DiffViewCard +import com.m57.hermescontrol.ui.chat.tool.ToolView +import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder import kotlinx.coroutines.delay import kotlinx.coroutines.launch -@Composable -private fun ExpandedToolContent( - parsed: ParsedToolData, - contentColor: Color, - statusColors: HermesStatusColors, -) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - // Summary line at top of expanded view - if (parsed.summaryText != null) { - Text( - text = parsed.summaryText, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.7f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - Spacer(modifier = Modifier.height(2.dp)) - } +/** + * Composes the collapsed summary lines for a tool card. + * + * The header row already shows the tool name, so when the engine's title is + * just the generic humanized name ("Fact Store" for fact_store) it is + * dropped and the subtitle merges into the emoji line instead — one + * identity per bubble. Descriptive titles ("Searched \"cats\"", "Ran ls") + * stay on line one with the subtitle on line two. + * + * Returns null when there is nothing to show (lone emoji, no subtitle). + */ +internal fun composeToolSummaryLines( + view: ToolView, + toolName: String?, + emoji: String, +): Pair? { + val titleIsGeneric = view.title.equals(ToolViewBuilder.genericTitleFor(toolName), ignoreCase = true) + val subtitle = view.subtitle.takeIf { it.isNotBlank() && it != view.title } - if (parsed.isTerminal) { - // ── Terminal output ── - parsed.stdout?.let { - Text( - text = it, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.9f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - } - parsed.error?.let { - Text( - text = stringResource(R.string.chat_tool_execution_error, it), - style = - MaterialTheme.typography.bodySmall.copy( - color = statusColors.error, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - } - parsed.exitCode?.let { code -> - if (code != 0) { - Text( - text = stringResource(R.string.chat_tool_exit_code, code), - style = - MaterialTheme.typography.labelSmall.copy( - color = statusColors.error, - fontWeight = FontWeight.Medium, - ), - ) + val firstLine: String + val secondLine: String? + if (titleIsGeneric) { + firstLine = + buildString { + append(emoji) + if (subtitle != null) { + append(" $subtitle") } + view.countLabel?.let { append(" ($it)") } } - - // Duration footer for terminal - parsed.durationSec?.let { dur -> - Text( - text = "Duration: ${"%.1f".format(dur)}s", - style = - MaterialTheme.typography.labelSmall.copy( - color = contentColor.copy(alpha = 0.5f), - ), - ) + secondLine = null + } else { + firstLine = + buildString { + append("$emoji ${view.title}") + view.countLabel?.let { append(" ($it)") } } - } else { - // ── Generic tool output / Diff view ── - if (parsed.diffOutput != null) { - DiffViewCard( - diffText = parsed.diffOutput, - filePath = parsed.diffPath, - ) - } else { - parsed.mainOutput?.let { - Text( - text = it, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.9f), - fontSize = 12.sp, - ), - ) - } - parsed.extraFields.forEach { (key, value) -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = "$key:", - style = - MaterialTheme.typography.labelSmall.copy( - color = contentColor.copy(alpha = 0.6f), - fontWeight = FontWeight.Bold, - ), - ) - Text( - text = value, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.9f), - fontSize = 11.sp, - ), - ) - } - } - } - } - // Duration footer - parsed.durationSec?.let { dur -> - Text( - text = "Duration: ${"%.1f".format(dur)}s", - style = - MaterialTheme.typography.labelSmall.copy( - color = contentColor.copy(alpha = 0.5f), - ), - ) - } + secondLine = subtitle } + + return firstLine.takeIf { it.trim() != emoji }?.let { it to secondLine } } @Composable @@ -199,7 +119,7 @@ internal fun ToolBubble( val contentColor = MaterialTheme.colorScheme.onSurfaceVariant val statusColors = LocalHermesStatusColors.current - val parsed = + val view = remember(message.content, message.toolName, message.toolStatus) { parseToolOutput(message.content, message.toolName, message.toolStatus == ToolStatus.RUNNING) } @@ -260,20 +180,36 @@ internal fun ToolBubble( SecurityRiskChip(riskData, contentColor) } - // ── Summary line (always visible when collapsed) ── - if (!expanded && parsed?.summaryText != null) { - Text( - text = parsed.summaryText, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.7f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 4.dp, start = 22.dp), - ) + // ── Collapsed summary: emoji + title, then subtitle ── + if (!expanded && view != null) { + composeToolSummaryLines(view, message.toolName, config.iconEmoji)?.let { (firstLine, secondLine) -> + Text( + text = firstLine, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.7f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp, start = 22.dp), + ) + if (secondLine != null) { + Text( + text = secondLine, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.5f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 1.dp, start = 22.dp), + ) + } + } } // ── Expanded content ── @@ -318,11 +254,11 @@ internal fun ToolBubble( .testTag("chat_tool_show_parsed") .clickable(role = Role.Button) { showRawJson = false }, ) - } else if (parsed != null) { + } else if (view != null) { // Clean structured expanded view Box { SelectionContainer { - ExpandedToolContent(parsed, contentColor, statusColors) + ExpandedToolContent(view, contentColor, statusColors) } CopyButton( visible = showCopyButton, @@ -387,6 +323,173 @@ internal fun ToolBubble( } } +/** + * Renders the engine's [ToolView] — the expanded body of a tool card. + * + * Order: error line → terminal streams ($ command / stdout / stderr / exit + * code) → file diff → search hits → plain detail → duration footer. + */ +@Composable +private fun ExpandedToolContent( + view: ToolView, + contentColor: Color, + statusColors: HermesStatusColors, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + val isTerminal = + view.stdout != null || + view.stderr != null || + view.exitCode != null || + view.terminalCommand != null + + // ── Error line ── + view.error?.let { + Text( + text = stringResource(R.string.chat_tool_execution_error, it), + style = + MaterialTheme.typography.bodySmall.copy( + color = statusColors.error, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + + // ── Terminal: $ command + streams + exit code ── + if (isTerminal) { + view.terminalCommand?.takeIf { it.isNotEmpty() }?.let { command -> + Text( + text = "$ $command", + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.7f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + view.stdout?.let { + Text( + text = it, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.9f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + view.stderr?.let { + Text( + text = it, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.6f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + view.exitCode?.let { code -> + if (code != 0) { + Text( + text = stringResource(R.string.chat_tool_exit_code, code), + style = + MaterialTheme.typography.labelSmall.copy( + color = statusColors.error, + fontWeight = FontWeight.Medium, + ), + ) + } + } + } else { + // ── File diff ── + if (view.inlineDiff != null) { + DiffViewCard( + diffText = view.inlineDiff, + filePath = view.diffPath, + ) + } + + // ── Search hits ── + if (!view.searchHits.isNullOrEmpty()) { + view.detailLabel?.let { label -> + Text( + text = label, + style = + MaterialTheme.typography.labelSmall.copy( + color = contentColor.copy(alpha = 0.6f), + fontWeight = FontWeight.Bold, + ), + ) + } + view.searchHits.forEach { hit -> + Column(modifier = Modifier.padding(top = 2.dp)) { + if (hit.title.isNotEmpty()) { + Text( + text = hit.title, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.9f), + fontWeight = FontWeight.Medium, + ), + ) + } + if (hit.snippet.isNotEmpty()) { + Text( + text = hit.snippet, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.6f), + fontSize = 11.sp, + ), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + if (hit.url.isNotEmpty()) { + Text( + text = "🔗 ${hit.url}", + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.7f), + fontSize = 11.sp, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + + // ── Plain detail body ── + if (view.detail.isNotBlank() && view.inlineDiff == null) { + Text( + text = view.detail, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.9f), + fontSize = 12.sp, + ), + ) + } + } + + // ── Duration footer ── + view.durationLabel?.let { + Text( + text = "Duration: $it", + style = + MaterialTheme.typography.labelSmall.copy( + color = contentColor.copy(alpha = 0.5f), + ), + ) + } + } +} + @Composable private fun HeaderRow( message: ChatMessage, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt index d0388422..24638217 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt @@ -1,1325 +1,58 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.remote.OkHttpProvider -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull +import com.m57.hermescontrol.ui.chat.tool.ToolView +import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.double -import kotlinx.serialization.json.int - -private fun formatTodoToolOutput( - dataSource: JsonObject, - obj: JsonObject, - resolvedToolName: String?, - isRunning: Boolean, -): ParsedToolData { - val todosArray = - dataSource - .get("todos") - ?.takeIf { !it.isJsonNull && it.isJsonArray } - ?.asJsonArray - val summaryObj = - dataSource - .get("summary") - ?.takeIf { !it.isJsonNull && it.isJsonObject } - ?.asJsonObject - - val todoSummaryText = - summaryObj?.let { s -> - val total = s.get("total")?.asDouble?.toInt() ?: 0 - val pending = s.get("pending")?.asDouble?.toInt() ?: 0 - val inProgress = s.get("in_progress")?.asDouble?.toInt() ?: 0 - val completed = s.get("completed")?.asDouble?.toInt() ?: 0 - val cancelled = s.get("cancelled")?.asDouble?.toInt() ?: 0 - val parts = mutableListOf() - if (pending > 0) parts.add("$pending pending") - if (inProgress > 0) parts.add("$inProgress in_progress") - if (completed > 0) parts.add("$completed completed") - if (cancelled > 0) parts.add("$cancelled cancelled") - val itemWord = if (total == 1) "item" else "items" - if (parts.isEmpty()) { - "📋 0 items" - } else { - "📋 $total $itemWord (${parts.joinToString(", ")})" - } - } - - val formattedTodos = - todosArray - ?.mapNotNull { element -> - if (!element.isJsonObject) return@mapNotNull null - val item = element.asJsonObject - val id = item.get("id")?.asString ?: "" - val content = item.get("content")?.asString ?: "" - val status = item.get("status")?.asString ?: "pending" - val marker = - when (status) { - "completed" -> "[x]" - "in_progress" -> "[>]" - "cancelled" -> "[~]" - else -> "[ ]" - } - "$marker $id. $content" - }?.joinToString("\n") - ?.takeIf { it.isNotEmpty() } - - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - - return ParsedToolData( - toolName = resolvedToolName ?: "", - summaryText = todoSummaryText, - mainOutput = formattedTodos, - durationSec = duration, - isRunning = isRunning, - ) -} /** - * Parses the tool.complete JSON payload into a [ParsedToolData]. - * The gateway sends a payload with `args` and `result` sub-objects - * (see tui_gateway/server.py `_on_tool_complete`). We extract both - * and use the tool name to build a one-line summary from the relevant arg. + * Parses the tool.complete JSON payload into a [ToolView] via the + * desktop-ported display engine ([ToolViewBuilder]). + * + * This is a thin adapter: it normalizes the payload shape (args/result + * sub-objects, legacy top-level fallback, name resolution) and hands the + * parts to the engine. All per-tool formatting lives in the engine. + * + * Returns null for non-JSON or non-object content — the view falls back + * to the raw JSON dump in that case. */ fun parseToolOutput( content: String, toolName: String?, isRunning: Boolean, -): ParsedToolData? { +): ToolView? { val trimmed = content.trim() - if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { + return null + } + return try { val element = OkHttpProvider.json.parseToJsonElement(trimmed) - if (!element.isJsonObject) return null - val obj = element.asJsonObject + if (element !is JsonObject) { + return null + } - // Resolve tool name — from parameter first, then from payload (old sessions) + // Resolve tool name — from the message first, then from the payload + // (old sessions carried it in the body). val resolvedToolName = toolName - ?: obj.get("name")?.takeIf { !it.isJsonNull }?.asString - - val config = ToolSchemaRegistry.getDisplayConfig(resolvedToolName) - - // Extract args sub-object if present (new tool.complete format) - val argsObj = obj.get("args")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject - val args: Map = - argsObj?.entrySet()?.associate { entry -> - entry.key to (if (entry.value.isJsonPrimitive) entry.value.asString else entry.value.toString()) - } ?: emptyMap() - - // Extract result sub-object if present, fall back to top-level (old format) - val resultObj = obj.get("result")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject - val dataSource = resultObj ?: obj - - // Build summary line from args (new format) or context field (old tool.start) - val summaryText = - if (config.summaryArgKey != null) { - val raw = args[config.summaryArgKey]?.toString() ?: "" - val truncated = if (raw.length > 100) raw.take(100) + "…" else raw - if (truncated.isNotBlank()) "${config.summaryPrefix}$truncated" else null - } else { - obj - .get("context") - ?.takeIf { !it.isJsonNull } - ?.asString - ?.takeIf { it.isNotEmpty() } - ?.let { "${config.summaryPrefix}$it" } - } - - if (resolvedToolName == "todo") { - return formatTodoToolOutput( - dataSource = dataSource, - obj = obj, - resolvedToolName = resolvedToolName, - isRunning = isRunning, - ) - } - - // ── Fact Store-specific formatting ── - // Extract structured fact results (probe/search/reason/related/contradict → results[], - // list → facts[], add/update/remove → simple status). Build a clean summary and - // formatted fact list with category, trust score, and tags. - if (resolvedToolName == "fact_store") { - val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" - val entity = argsObj?.get("entity")?.takeIf { !it.isJsonNull }?.asString - val query = argsObj?.get("query")?.takeIf { !it.isJsonNull }?.asString - - // Try both key shapes: results[] (probe/search/reason/related/contradict) and facts[] (list) - val factsArray = - dataSource - .get("results") - ?.takeIf { !it.isJsonNull && it.isJsonArray } - ?.asJsonArray - ?: dataSource - .get("facts") - ?.takeIf { !it.isJsonNull && it.isJsonArray } - ?.asJsonArray - - val count = - dataSource - .get("count") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() ?: 0 - val status = dataSource.get("status")?.takeIf { !it.isJsonNull }?.asString - val factId = - dataSource - .get("fact_id") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val removed = dataSource.get("removed")?.takeIf { !it.isJsonNull }?.asBoolean - val updated = dataSource.get("updated")?.takeIf { !it.isJsonNull }?.asBoolean - - // Build summary line - val actionContext = - when { - entity != null -> " ($action: $entity)" - query != null -> " ($action: $query)" - else -> " ($action)" - } - val factSummaryText = - when { - status == "added" && factId != null -> "🧠 Fact added (ID: $factId)" - status == "added" -> "🧠 Fact added" - removed == true -> "🧠 Fact removed" - updated == true -> "🧠 Fact updated" - factsArray != null -> "🧠 $count facts$actionContext" - else -> "🧠 $action" - } - - // Format each fact as a compact block: #ID + content on first line, meta on indented second line - val formattedFacts = - factsArray - ?.mapNotNull { element -> - if (!element.isJsonObject) return@mapNotNull null - val item = element.asJsonObject - val fid = item.get("fact_id")?.asDouble?.toInt() ?: 0 - val content = item.get("content")?.asString ?: "" - val category = item.get("category")?.asString - val trust = item.get("trust_score")?.asDouble - val tags = item.get("tags")?.asString?.takeIf { it.isNotEmpty() } - - val firstLine = "#$fid $content" - val metaParts = mutableListOf() - if (category != null) metaParts.add("[$category]") - if (trust != null) metaParts.add("trust: ${"%.2f".format(trust)}") - if (tags != null) metaParts.add("🏷️ $tags") - if (metaParts.isNotEmpty()) { - "$firstLine\n ${metaParts.joinToString(" ")}" - } else { - firstLine - } - }?.joinToString("\n") - ?.takeIf { it.isNotEmpty() } - - val factMainOutput = - when { - status == "added" && factId != null -> "✅ Fact #$factId stored" - status == "added" -> "✅ Fact stored" - removed == true -> "✅ Removed" - updated == true -> "✅ Updated" - formattedFacts != null -> formattedFacts - else -> null - } - - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - - return ParsedToolData( - toolName = resolvedToolName, - summaryText = factSummaryText, - mainOutput = factMainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Session Search-specific formatting ── - // Handles all 4 calling modes: discover (results[] with snippets/bookends), - // scroll (messages[] with anchor), read (messages[] with session dump), - // and browse (results[] with recent session metadata). - if (resolvedToolName == "session_search") { - val mode = dataSource.get("mode")?.takeIf { !it.isJsonNull }?.asString ?: "" - val query = dataSource.get("query")?.takeIf { !it.isJsonNull }?.asString - val resultsArray = - dataSource.get("results")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val messagesArray = - dataSource.get("messages")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val count = - dataSource - .get("count") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val msgCount = - dataSource - .get("message_count") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val truncated = dataSource.get("truncated")?.takeIf { !it.isJsonNull }?.asBoolean - - // Build summary line - val ssSummaryText = - when (mode) { - "discover" -> "🔍 ${count ?: 0} session${if (count == 1) "" else "s"}${query?.let { - ": ${it.take( - 60, - )}" - } ?: ""}" - - "scroll" -> "📜 ${messagesArray?.size() ?: 0} messages (scroll)" - - "read" -> "📖 ${msgCount ?: messagesArray?.size() ?: 0} messages${ - if (truncated == true) " (truncated)" else "" - }" - - "browse" -> "📋 ${count ?: 0} recent sessions" - - else -> "🔍 session_search" - } - - // Format results (discover / browse) - val formattedResults = - resultsArray - ?.mapIndexedNotNull { idx, element -> - if (!element.isJsonObject) return@mapIndexedNotNull null - val item = element.asJsonObject - val whenField = - item.get("when")?.takeIf { !it.isJsonNull }?.asString - ?: item.get("started_at")?.takeIf { !it.isJsonNull }?.asString - val source = item.get("source")?.takeIf { !it.isJsonNull }?.asString - val title = item.get("title")?.takeIf { !it.isJsonNull }?.asString - val snippet = item.get("snippet")?.takeIf { !it.isJsonNull }?.asString - val preview = item.get("preview")?.takeIf { !it.isJsonNull }?.asString - val model = item.get("model")?.takeIf { !it.isJsonNull }?.asString - val sessionMsgCount = - item - .get("message_count") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val matchedRole = item.get("matched_role")?.takeIf { !it.isJsonNull }?.asString - - val header = whenField?.let { "📅 $it" } ?: "" - val sourceTag = source?.let { "[$it]" } ?: "" - val titleLine = title?.let { "\n 📄 $it" } ?: "" - val modelLine = model?.let { "\n 🤖 $it" } ?: "" - val matchedLine = matchedRole?.let { "\n 🎯 matched: $it" } ?: "" - val countLine = sessionMsgCount?.let { "\n $it msgs" } ?: "" - val snippetText = snippet ?: preview - val snippetLine = - snippetText?.let { - val clean = it.take(200).replace("\n", " ") - "\n ┃ $clean" - } ?: "" - - "━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine" - }?.joinToString("\n") - ?.takeIf { it.isNotEmpty() } - - // Format messages (scroll / read) - val anchorId = - dataSource - .get("around_message_id") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val formattedMessages = - messagesArray - ?.mapNotNull { element -> - if (!element.isJsonObject) return@mapNotNull null - val item = element.asJsonObject - val msgId = - item - .get("id") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val role = item.get("role")?.takeIf { !it.isJsonNull }?.asString ?: "?" - val content = item.get("content")?.takeIf { !it.isJsonNull }?.asString ?: "" - val toolName = item.get("tool_name")?.takeIf { !it.isJsonNull }?.asString - - val roleEmoji = - when (role) { - "user" -> "👤" - "assistant" -> "🤖" - "tool" -> "🔧" - else -> "❓" - } - val namePart = if (toolName != null) " ($toolName)" else "" - val anchor = if (anchorId != null && msgId == anchorId) " ⬅️" else "" - val cleanContent = content.take(300).replace("\n", " ") - - "[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent" - }?.joinToString("\n") - ?.takeIf { it.isNotEmpty() } - - val ssMainOutput = formattedResults ?: formattedMessages - - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - - return ParsedToolData( - toolName = resolvedToolName, - summaryText = ssSummaryText, - mainOutput = ssMainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Memory-specific formatting ── - if (resolvedToolName == "memory") { - val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" - val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true - val target = argsObj?.get("target")?.takeIf { !it.isJsonNull }?.asString - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - val usage = dataSource.get("usage")?.takeIf { !it.isJsonNull }?.asString - - val summaryText = - when (action.lowercase()) { - "add" -> "🧠 Memory Saved${target?.let { " ($it)" } ?: ""}" - "replace", "update" -> "🧠 Memory Updated${target?.let { " ($it)" } ?: ""}" - "remove", "delete" -> "🧠 Memory Removed${target?.let { " ($it)" } ?: ""}" - else -> "🧠 Memory $action${target?.let { " ($it)" } ?: ""}" - } - val mainOutput = - when { - errorMsg != null -> "❌ $errorMsg" - - !success -> "❌ Failed" - - usage != null -> "✅ $action in ${target ?: "memory"}\n $usage" - - else -> "✅ ${target?.replaceFirstChar { - if (it.isLowerCase()) { - it.titlecase( - java.util.Locale.getDefault(), - ) - } else { - it.toString() - } - } ?: "Memory"} $action done" - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Skill Manage-specific formatting (Self-Improvement) ── - if (resolvedToolName == "skill_manage") { - val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" - val name = argsObj?.get("name")?.takeIf { !it.isJsonNull }?.asString - val category = argsObj?.get("category")?.takeIf { !it.isJsonNull }?.asString - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true - val msg = dataSource.get("message")?.takeIf { !it.isJsonNull }?.asString - - val summaryText = - when (action.lowercase()) { - "create" -> "⚡ Skill Created${name?.let { ": $it" } ?: ""}" - "patch" -> "⚡ Skill Patched${name?.let { ": $it" } ?: ""}" - "edit" -> "⚡ Skill Edited${name?.let { ": $it" } ?: ""}" - "delete" -> "⚡ Skill Archived${name?.let { ": $it" } ?: ""}" - "write_file" -> "⚡ Skill File Written${name?.let { ": $it" } ?: ""}" - "remove_file" -> "⚡ Skill File Removed${name?.let { ": $it" } ?: ""}" - else -> - "⚡ Skill ${action.replaceFirstChar { - if (it.isLowerCase()) it.titlecase(java.util.Locale.getDefault()) else it.toString() - }}${name?.let { ": $it" } ?: ""}" - } - val mainOutput = - when { - errorMsg != null -> "❌ $errorMsg" - !success -> "❌ Skill operation failed" - msg != null -> "✅ $msg" - name != null -> "✅ Skill '$name' $action succeeded${category?.let { " [$it]" } ?: ""}" - else -> "✅ Skill $action done" - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Cronjob-specific formatting ── - if (resolvedToolName == "cronjob") { - val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" - val jobsArray = dataSource.get("jobs")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val jobId = - dataSource - .get("job_id") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val jobName = dataSource.get("name")?.takeIf { !it.isJsonNull }?.asString - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - - val summaryText = "🔄 $action${jobName?.let { ": $it" } ?: jobId?.let { " (ID: $it)" } ?: ""}" - val mainOutput = - when { - errorMsg != null -> { - "❌ $errorMsg" - } - - jobsArray != null -> { - jobsArray - .mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val j = el.asJsonObject - val name = j.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "?" - val schedule = j.get("schedule")?.takeIf { !it.isJsonNull }?.asString - val status = j.get("status")?.takeIf { !it.isJsonNull }?.asString - val lastRun = j.get("last_run")?.takeIf { !it.isJsonNull }?.asString - val parts = mutableListOf("📌 $name") - if (schedule != null) parts.add(" ⏱ $schedule") - if (status != null) parts.add(" 📊 $status") - if (lastRun != null) parts.add(" ⏮ $lastRun") - parts.joinToString("\n") - }.joinToString("\n\n") - } - - jobId != null -> { - "✅ $action (ID: $jobId)" - } - - else -> { - "✅ $action done" - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Web Search-specific formatting ── - if (resolvedToolName == "web_search") { - val query = argsObj?.get("query")?.takeIf { !it.isJsonNull }?.asString ?: "" - val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - val webArr = - dataSource - .get("data") - ?.takeIf { !it.isJsonNull && it.isJsonObject } - ?.asJsonObject - ?.get("web") - ?.takeIf { !it.isJsonNull && it.isJsonArray } - ?.asJsonArray - - val summaryText = "🌐 $query${webArr?.let { " (${it.size()} results)" } ?: ""}" - val mainOutput = - when { - errorMsg != null -> { - "❌ $errorMsg" - } - - webArr != null -> { - webArr - .mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val item = el.asJsonObject - val title = item.get("title")?.takeIf { !it.isJsonNull }?.asString ?: "" - val url = item.get("url")?.takeIf { !it.isJsonNull }?.asString - val desc = item.get("description")?.takeIf { !it.isJsonNull }?.asString - val pos = - item - .get("position") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val lines = mutableListOf("${pos?.let { "$it. " } ?: ""}$title") - if (desc != null) lines.add(" $desc") - if (url != null) lines.add(" 🔗 $url") - lines.joinToString("\n") - }.joinToString("\n\n") - } - - !success -> { - "❌ Search failed" - } - - else -> { - "No results" - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Skills List-specific formatting ── - if (resolvedToolName == "skills_list") { - val category = argsObj?.get("category")?.takeIf { !it.isJsonNull }?.asString - val skillsArr = - (dataSource.get("skills") ?: dataSource.get("results")) - ?.takeIf { !it.isJsonNull && it.isJsonArray } - ?.asJsonArray - - val summaryText = "📚 ${skillsArr?.size() ?: 0} skills${category?.let { " ($it)" } ?: ""}" - val mainOutput = - skillsArr - ?.mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val s = el.asJsonObject - val name = s.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "" - val desc = s.get("description")?.takeIf { !it.isJsonNull }?.asString - val cat = s.get("category")?.takeIf { !it.isJsonNull }?.asString - val lines = mutableListOf("📌 $name") - if (desc != null) lines.add(" $desc") - if (cat != null) lines.add(" [$cat]") - lines.joinToString("\n") - }?.joinToString("\n\n") ?: "📚 No skills found" - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Skill View-specific formatting ── - if (resolvedToolName == "skill_view") { - val name = argsObj?.get("name")?.takeIf { !it.isJsonNull }?.asString - val content = dataSource.get("content")?.takeIf { !it.isJsonNull }?.asString - val linkedFiles = dataSource.get("linked_files")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject - - val summaryText = "📖 $name" - val contentPreview = - content?.let { c -> - if (c.length > 500) c.take(500) + "\n... [${c.length - 500} more chars]" else c - } - val filesInfo = - linkedFiles?.entrySet()?.joinToString("\n") { (k, v) -> - val paths = - when { - v.isJsonArray -> v.asJsonArray.joinToString(", ") { it.asString } - v.isJsonPrimitive -> v.asString - else -> v.toString() - } - " 📎 $k: $paths" - } - val mainOutput = - buildString { - if (contentPreview != null) append(contentPreview) - if (filesInfo != null) append("\n\n━━━ Linked Files ━━━\n$filesInfo") - if (content == null && linkedFiles == null) append("📖 No content available") - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Process-specific formatting ── - if (resolvedToolName == "process") { - val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" - val procId = argsObj?.get("session_id")?.takeIf { !it.isJsonNull }?.asString - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - val status = dataSource.get("status")?.takeIf { !it.isJsonNull }?.asString - val outputText = dataSource.get("output")?.takeIf { !it.isJsonNull }?.asString - val processesArr = dataSource.get("processes")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - - val summaryText = "⚙️ $action${procId?.let { ": $it" } ?: ""}" - val mainOutput = - when { - errorMsg != null -> { - "❌ $errorMsg" - } - - processesArr != null -> { - processesArr - .mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val p = el.asJsonObject - val pid = - p - .get( - "session_id", - )?.takeIf { !it.isJsonNull } - ?.asString ?: p.get("id")?.asString ?: "?" - val pStatus = p.get("status")?.takeIf { !it.isJsonNull }?.asString - val cmd = p.get("command")?.takeIf { !it.isJsonNull }?.asString - val running = p.get("running")?.takeIf { !it.isJsonNull }?.asBoolean - val parts = mutableListOf("📌 $pid${cmd?.let { ": $it" } ?: ""}") - if (pStatus != null) parts.add(" Status: $pStatus") - if (running != null) parts.add(" Running: $running") - parts.joinToString("\n") - }.joinToString("\n\n") - } - - outputText != null -> { - "${status?.let { "Status: $it\n" } ?: ""}$outputText" - } - - status != null -> { - "✅ $status" - } - - else -> { - "✅ $action done" - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── X Search-specific formatting ── - if (resolvedToolName == "x_search") { - val query = argsObj?.get("query")?.takeIf { !it.isJsonNull }?.asString ?: "" - val answer = dataSource.get("answer")?.takeIf { !it.isJsonNull }?.asString - val citationsArr = dataSource.get("citations")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val degraded = dataSource.get("degraded")?.takeIf { !it.isJsonNull }?.asBoolean - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - - val summaryText = "𝕏 $query${if (degraded == true) " (no citations)" else ""}" - val mainOutput = - when { - errorMsg != null -> { - "❌ $errorMsg" - } - - answer != null -> { - val lines = mutableListOf(answer) - if (citationsArr != null && citationsArr.size() > 0) { - lines.add("\n━━━ Citations ━━━") - citationsArr.forEachIndexed { idx, cit -> - if (cit.isJsonObject) { - val url = cit.asJsonObject.get("url")?.asString - val title = cit.asJsonObject.get("title")?.asString - lines.add("${idx + 1}. ${title ?: url ?: "source"}") - if (title != null && url != null) lines.add(" $url") - } - } - } - if (degraded == true) lines.add("\n⚠️ No citations — answer based on model's knowledge") - lines.joinToString("\n") - } - - else -> { - "𝕏 No results" - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Vision Analyze-specific formatting ── - if (resolvedToolName == "vision_analyze") { - val imageUrl = argsObj?.get("image_url")?.takeIf { !it.isJsonNull }?.asString ?: "" - val description = dataSource.get("description")?.takeIf { !it.isJsonNull }?.asString - val content = dataSource.get("content")?.takeIf { !it.isJsonNull }?.asString - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - - val summaryText = "👁️ ${imageUrl.take(60)}" - val mainOutput = - when { - errorMsg != null -> "❌ $errorMsg" - description != null -> description - content != null -> content - else -> "👁️ No description available" - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Web Extract-specific formatting ── - if (resolvedToolName == "web_extract") { - val urls = argsObj?.get("urls")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val content = dataSource.get("content")?.takeIf { !it.isJsonNull }?.asString - val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - - val urlSummary = urls?.joinToString(", ") { it.asString.take(40) } ?: "" - val summaryText = "🕸️ ${urls?.size() ?: 0} page(s)" - val mainOutput = - when { - errorMsg != null -> { - "❌ $errorMsg" - } - - content != null -> { - content.take( - 3000, - ) + if (content.length > 3000) "\n... [${content.length - 3000} more chars]" else "" - } - - else -> { - "🕸️ No content extracted" - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Tool Search-specific formatting ── - if (resolvedToolName == "tool_search") { - val query = argsObj?.get("query")?.takeIf { !it.isJsonNull }?.asString ?: "" - val limit = - argsObj - ?.get("limit") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val matchesArr = dataSource.get("matches")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - - val summaryText = "🔍 $query${matchesArr?.let { " (${it.size()} matches)" } ?: ""}" - val mainOutput = - matchesArr - ?.mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val m = el.asJsonObject - val name = m.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "?" - val desc = m.get("description")?.takeIf { !it.isJsonNull }?.asString - val lines = mutableListOf("🔧 $name") - if (desc != null) lines.add(" $desc") - lines.joinToString("\n") - }?.joinToString("\n\n") ?: "🔍 No matching tools" - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Image Generate-specific formatting ── - if (resolvedToolName == "image_generate") { - val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - val imageUrl = dataSource.get("image")?.takeIf { !it.isJsonNull }?.asString - val argsImageUrl = argsObj?.get("image_url")?.takeIf { !it.isJsonNull }?.asString - val modality = dataSource.get("modality")?.takeIf { !it.isJsonNull }?.asString - val promptUrl = argsObj?.get("prompt")?.takeIf { !it.isJsonNull }?.asString - - val summaryText = - buildString { - append("🎨 ") - if (errorMsg != null) { - append("❌ $errorMsg") - } else { - append((imageUrl ?: argsImageUrl ?: "").take(60)) - if (modality != null) append(" ($modality)") - } - } - val mainOutput = - buildString { - if (errorMsg != null) { - append("❌ $errorMsg") - if (imageUrl != null) append("\n🔗 $imageUrl") - } else if (imageUrl != null) { - append("🖼️ ") - if (promptUrl != null) append("$promptUrl\n") - append("\n🔗 $imageUrl") - } else { - append("✅ Generated") - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Project List-specific formatting ── - if (resolvedToolName == "project_list") { - val activeId = dataSource.get("active_id")?.takeIf { !it.isJsonNull }?.asString - val projectsArr = dataSource.get("projects")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - - val summaryText = "📂 ${projectsArr?.size() ?: "?"} projects${activeId?.let { " (1 active)" } ?: ""}" - val mainOutput = - if (projectsArr != null) { - projectsArr - .mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val p = el.asJsonObject - val id = p.get("id")?.takeIf { !it.isJsonNull }?.asString ?: "" - val name = p.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "" - val slug = p.get("slug")?.takeIf { !it.isJsonNull }?.asString - val path = p.get("primary_path")?.takeIf { !it.isJsonNull }?.asString - val isActive = p.get("active")?.takeIf { !it.isJsonNull }?.asBoolean ?: false - val lines = mutableListOf("${if (isActive) "⭐ " else "📁 "}$name") - if (slug != null) lines.add(" 🏷️ $slug") - if (path != null) lines.add(" 📍 $path") - if (isActive) lines.add(" ✅ ACTIVE PROJECT") - lines.joinToString("\n") - }.joinToString("\n\n") - } else { - "" - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Project Create/Switch-specific formatting ── - if (resolvedToolName == "project_create" || resolvedToolName == "project_switch") { - val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - val projName = dataSource.get("name")?.takeIf { !it.isJsonNull }?.asString - val projSlug = dataSource.get("slug")?.takeIf { !it.isJsonNull }?.asString - val projPath = dataSource.get("primary_path")?.takeIf { !it.isJsonNull }?.asString - val actionLabel = if (resolvedToolName == "project_create") "Created" else "Switched to" - - val summaryText = "📂 $actionLabel${projName?.let { " $it" } ?: ""}" - val mainOutput = - buildString { - if (errorMsg != null) { - append("❌ $errorMsg") - } else { - append("✅ $actionLabel") - if (projName != null) append(": $projName") - if (projSlug != null) append("\n 🏷️ $projSlug") - if (projPath != null) append("\n 📍 $projPath") - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Read Terminal-specific formatting ── - if (resolvedToolName == "read_terminal") { - val totalLines = dataSource.get("total_lines")?.takeIf { !it.isJsonNull }?.asInt ?: 0 - val startLine = dataSource.get("start")?.takeIf { !it.isJsonNull }?.asInt ?: 0 - val endLine = dataSource.get("end")?.takeIf { !it.isJsonNull }?.asInt ?: 0 - val cursorRow = dataSource.get("cursor_row")?.takeIf { !it.isJsonNull }?.asInt - val text = dataSource.get("text")?.takeIf { !it.isJsonNull }?.asString - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - - val summaryText = - buildString { - append("🖥️ ") - if (errorMsg != null) { - append("❌ $errorMsg") - } else if (text != null) { - append("Lines $startLine-$endLine of $totalLines") - if (cursorRow != null) append(", cursor at row $cursorRow") - } else { - append("Terminal output") - } - } - val mainOutput = - buildString { - if (errorMsg != null) { - append("❌ $errorMsg") - } else if (text != null) { - append("━━━ Terminal ━━━ ($startLine:$endLine / $totalLines)") - if (cursorRow != null) append(" │ cursor row $cursorRow") - append("\n$text") - } else { - append("🖥️ No terminal output") - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Computer Use-specific formatting ── - if (resolvedToolName == "computer_use") { - val action = dataSource.get("action")?.takeIf { !it.isJsonNull }?.asString - val ok = dataSource.get("ok")?.takeIf { !it.isJsonNull }?.asBoolean - val message = dataSource.get("message")?.takeIf { !it.isJsonNull }?.asString - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - val isMultimodal = dataSource.get("_multimodal")?.takeIf { !it.isJsonNull }?.asBoolean ?: false - val mode = dataSource.get("mode")?.takeIf { !it.isJsonNull }?.asString - val elementsArr = dataSource.get("elements")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val appsArr = dataSource.get("apps")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val summaryText = dataSource.get("text_summary")?.takeIf { !it.isJsonNull }?.asString - val textSummary = dataSource.get("summary")?.takeIf { !it.isJsonNull }?.asString - val visionAnalysis = dataSource.get("vision_analysis")?.takeIf { !it.isJsonNull }?.asString - val totalElements = dataSource.get("total_elements")?.takeIf { !it.isJsonNull }?.asInt - val width = dataSource.get("width")?.takeIf { !it.isJsonNull }?.asInt - val height = dataSource.get("height")?.takeIf { !it.isJsonNull }?.asInt - - // Try to extract text from multimodal content array - val multimodalText = - if (isMultimodal) { - val contentArr = - dataSource.get("content")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - contentArr - ?.mapNotNull { el -> - if (el.isJsonObject) { - val eObj = el.asJsonObject - if (eObj.get("type")?.takeIf { !it.isJsonNull }?.asString == "text") { - eObj.get("text")?.takeIf { !it.isJsonNull }?.asString - } else { - null - } - } else { - null - } - }?.joinToString("\n") - } else { - null - } - - val summaryLine = - buildString { - append("🖥️ ") - if (errorMsg != null) { - append("❌ $errorMsg") - } else if (appsArr != null) { - append("${appsArr.size()} apps") - } else if (elementsArr != null) { - append("${elementsArr.size()} elements${mode?.let { " ($it)" } ?: ""}") - if (width != null && height != null) append(" ${width}x$height") - } else if (action != null) { - append("$action${ok?.let { if (it) " ✓" else " ✗" } ?: ""}") - } else { - append("Computer use") - } - } - val mainBody = - buildString { - if (errorMsg != null) { - append("❌ $errorMsg") - } else { - val textPart = multimodalText ?: summaryText ?: textSummary - if (textPart != null) { - append(textPart) - if (textPart == multimodalText && textSummary != null && multimodalText != textSummary) { - append("\n\n$textSummary") - } - } - - if (appsArr != null) { - if (isNotEmpty()) append("\n\n") - appsArr.forEachIndexed { idx, el -> - if (el.isJsonObject) { - val obj = el.asJsonObject - val name = - obj - .get("name") - ?.takeIf { !it.isJsonNull } - ?.asString ?: "?" - val pid = - obj - .get("pid") - ?.takeIf { !it.isJsonNull } - ?.asInt - append("${idx + 1}. $name") - if (pid != null) append(" (PID: $pid)") - append("\n") - } - } - } else if (elementsArr != null) { - if (isNotEmpty()) append("\n\n") - append("🖥️ ${elementsArr.size()} interactable elements") - if (mode != null) append(" (mode: $mode)") - if (width != null && height != null) append(" • ${width}x$height") - if (textSummary != null) append("\n\n$textSummary") - // Show first few elements as preview - val previewCount = minOf(elementsArr.size(), 5) - if (previewCount > 0) { - append("\n\n") - for (i in 0 until previewCount) { - val el = elementsArr[i] - if (el.isJsonObject) { - val obj = el.asJsonObject - val idx = - obj - .get("index") - ?.takeIf { !it.isJsonNull } - ?.asInt - val role = - obj - .get("role") - ?.takeIf { !it.isJsonNull } - ?.asString - val label = - obj - .get("label") - ?.takeIf { !it.isJsonNull } - ?.asString - append(" #$idx ${role ?: ""}${label?.let { " '$it'" } ?: ""}\n") - } - } - if (elementsArr.size() > previewCount) { - append(" ... and ${elementsArr.size() - previewCount} more") - } - } - } else if (action != null && - (multimodalText == null && summaryText == null && textSummary == null) - ) { - append("$action:") - if (ok != null) append(" ${if (ok) "✅ ok" else "❌ failed"}") - if (message != null) append("\n$message") - } else if (isEmpty()) { - append("🖥️ Computer use action") - } - } - if (visionAnalysis != null) { - append("\n\n━━━ Vision Analysis ━━━\n$visionAnalysis") - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - summaryText = summaryLine, - mainOutput = mainBody, - durationSec = duration, - isRunning = isRunning, - ) - } - - // Terminal result. The backend (tools/terminal_tool.py) emits a single - // `output` string (stdout+stderr merged, ANSI-stripped), plus `exit_code` - // and an `error` string on failure. Older payloads used `stdout`/`stderr` - // — accept both for backward compat, but `output` is canonical. - val hasOutput = dataSource.has("output") - val hasStdout = dataSource.has("stdout") - val hasStderr = dataSource.has("stderr") - val hasExitCode = dataSource.has("exit_code") || dataSource.has("exitCode") - - val isTerminalTool = - resolvedToolName == "terminal" || - resolvedToolName == "bash" || - resolvedToolName == "sh" || - resolvedToolName == "cmd" || - resolvedToolName == "exec" - - val isDiffTool = - resolvedToolName == "patch" || - resolvedToolName == "write_file" || - resolvedToolName == "edit" - - if (!isDiffTool && (isTerminalTool || hasOutput || hasStdout || hasStderr || hasExitCode)) { - val terminalOutput = - ( - dataSource.get("output")?.takeIf { !it.isJsonNull }?.asString - ?: listOfNotNull( - dataSource - .get("stdout") - ?.takeIf { !it.isJsonNull } - ?.asString - ?.takeIf { it.isNotEmpty() }, - dataSource - .get("stderr") - ?.takeIf { !it.isJsonNull } - ?.asString - ?.takeIf { it.isNotEmpty() }, - ).joinToString("\n").takeIf { it.isNotEmpty() } - )?.takeIf { it.isNotEmpty() } - val exitCode = (dataSource.get("exit_code") ?: dataSource.get("exitCode"))?.toIntOrNull() - val error = - dataSource - .get("error") - ?.takeIf { !it.isJsonNull } - ?.asString - ?.takeIf { it.isNotEmpty() } - // Surface non-zero exit codes as an error line even when `error` is absent. - val effectiveError = - if (error != null) { - error - } else if (exitCode != null && exitCode != 0 && terminalOutput == null) { - "exit code $exitCode" - } else { - null - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - - ParsedToolData( - toolName = resolvedToolName ?: "", - isTerminal = true, - stdout = terminalOutput, - exitCode = exitCode, - error = effectiveError, - summaryText = summaryText, - mainOutput = terminalOutput, - durationSec = duration, - isRunning = isRunning, - ) - } else { - // Generic tool display - val mainOutput = - dataSource - .let { r -> - (r.get("output") ?: r.get("result") ?: r.get("content") ?: r.get("text")) - ?.takeIf { !it.isJsonNull } - ?.let { if (it.isJsonPrimitive) it.asString else it.toString() } - ?.takeIf { it.isNotEmpty() } - } - - val extraFields = mutableMapOf() - dataSource.entrySet().forEach { (key, value) -> - if (key != "output" && key != "result" && key != "content" && key != "text" && - key != "name" && key != "tool_id" && key != "context" && !value.isJsonNull - ) { - val valStr = if (value.isJsonPrimitive) value.asString else value.toString() - if (valStr.isNotEmpty() && valStr != "null") { - extraFields[key] = valStr - } - } - } - - val diffCandidate = - dataSource.get("diff")?.takeIf { !it.isJsonNull }?.let { - if (it.isJsonPrimitive) it.asString else it.toString() - } - ?: dataSource.get("patch")?.takeIf { !it.isJsonNull }?.let { - if (it.isJsonPrimitive) it.asString else it.toString() - } - ?: mainOutput - - val filePathArg = - args["path"]?.toString() - ?: argsObj?.get("path")?.takeIf { !it.isJsonNull }?.asString - - val oldStrArg = args["old_string"]?.toString() - val newStrArg = args["new_string"]?.toString() - - val isDiffTool = - resolvedToolName == "patch" || - resolvedToolName == "edit" || - resolvedToolName == "write_file" - - val extractedDiffOutput = - when { - diffCandidate != null && ( - diffCandidate.contains("\n-") || - diffCandidate.contains("\n+") || - diffCandidate.startsWith("--- ") || - diffCandidate.startsWith("+++ ") || - diffCandidate.startsWith("*** ") || - diffCandidate.startsWith("@@ ") - ) -> diffCandidate - - isDiffTool && oldStrArg != null && newStrArg != null -> { - val pathHeader = filePathArg ?: "file" - "--- $pathHeader\n+++ $pathHeader\n@@ -1,1 +1,1 @@\n-$oldStrArg\n+$newStrArg" - } - - isDiffTool && args["patch"] != null -> { - args["patch"].toString() - } - - else -> null - } - - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - - ParsedToolData( - toolName = resolvedToolName ?: "", - summaryText = summaryText, - mainOutput = mainOutput, - extraFields = extraFields, - durationSec = duration, - isRunning = isRunning, - diffOutput = extractedDiffOutput, - diffPath = filePathArg, - ) - } - } catch (e: Exception) { + ?: (element["name"] as? JsonPrimitive)?.takeIf { it.isString }?.content + + // Extract args sub-object if present (new tool.complete format) and + // the result sub-object, falling back to the top-level payload (old + // format where result fields sat at the root). + val args = element["args"] as? JsonObject + val result = element["result"] as? JsonObject ?: element + + ToolViewBuilder.build( + toolName = resolvedToolName ?: "tool", + args = args, + result = result, + isError = false, + running = isRunning, + ) + } catch (_: Exception) { null } } - -// Extensions for backward compatibility with Gson API under kotlinx.serialization -private val JsonElement.isJsonObject: Boolean get() = this is JsonObject -private val JsonElement.asJsonObject: JsonObject get() = this as JsonObject -private val JsonElement.isJsonArray: Boolean get() = this is JsonArray -private val JsonElement.asJsonArray: JsonArray get() = this as JsonArray -private val JsonElement.isJsonNull: Boolean get() = this is JsonNull -private val JsonElement.isJsonPrimitive: Boolean get() = this is JsonPrimitive -private val JsonElement.asString: String get() = (this as JsonPrimitive).content -private val JsonElement.asDouble: Double get() = (this as JsonPrimitive).double -private val JsonElement.asInt: Int get() = (this as JsonPrimitive).int -private val JsonElement.asBoolean: Boolean get() = (this as JsonPrimitive).boolean - -private fun JsonElement.has(key: String): Boolean = (this as? JsonObject)?.containsKey(key) == true - -private fun JsonObject.entrySet(): Set> = entries - -private fun JsonArray.size(): Int = size - -/** - * Parse a numeric/string JSON primitive into an Int, tolerating backend quirks - * (e.g. terminal `exit_code` arrives as a FLOAT `0.0` or a STRING). Returns null - * if missing, null, or non-numeric — callers must not throw, or the whole tool - * parse is caught and the bubble falls back to raw JSON. - */ -private fun JsonElement?.toIntOrNull(): Int? { - if (this == null || this is JsonNull) return null - return when (this) { - is JsonPrimitive -> { - content.toIntOrNull() ?: content.toDoubleOrNull()?.toInt() - } - - else -> { - null - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index 3df8d128..f8cb3d23 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -47,10 +47,13 @@ object ToolViewBuilder { if (v is JsonNull) { return null } - if (v !is JsonPrimitive || v.isString) { + if (v !is JsonPrimitive) { return null } + // TS source does `Number(value)` — numeric STRINGS ("0.0", "12") are + // accepted, matching the desktop engine and the gateway's habit of + // shipping exit_code as a string or float. return v.content.toDoubleOrNull() } @@ -383,6 +386,13 @@ object ToolViewBuilder { // ── titles ─────────────────────────────────────────────────────────── + /** + * The generic humanized title for a tool name ("fact_store" -> "Fact + * Store"). Exposed so the view can drop the title from the collapsed + * summary when it adds nothing over the header row's own tool name. + */ + internal fun genericTitleFor(toolName: String?): String = titleForTool(toolName ?: "tool") + private fun titleForTool(name: String): String { val normalized = name.replace(Regex("^browser_"), "").replace(Regex("^web_"), "") @@ -946,12 +956,19 @@ object ToolViewBuilder { } } "memory" -> { - when (firstString(args, listOf("action")).lowercase()) { - "replace", "update" -> "Memory Updated" - "remove", "delete" -> "Memory Removed" - "add" -> "Memory Saved" - else -> "Memory ${firstString(args, listOf("action"))}" - } + val action = firstString(args, listOf("action")).lowercase() + val target = firstString(args, listOf("target")) + val base = + when (action) { + "replace", "update" -> "Memory Updated" + "remove", "delete" -> "Memory Removed" + "add" -> "Memory Saved" + else -> "Memory ${firstString(args, listOf("action"))}" + } + + // Keep the target visible — "Memory Saved (user)" — like the + // pre-engine mobile summary did. + if (target.isNotEmpty()) "$base ($target)" else base } "cronjob" -> "Cron ${firstString(args, listOf("action")).ifEmpty { "manage" }}" "edit_file", "patch", "write_file" -> { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt index 59c47222..4c288099 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt @@ -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) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolSummaryLinesTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolSummaryLinesTest.kt new file mode 100644 index 00000000..2666c96d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolSummaryLinesTest.kt @@ -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) + } +}