feat(chat): port desktop tool display engine (ToolResultSummary + ToolViewBuilder) (#763)
* feat(chat): port desktop heuristic JSON summarizer engine New com.m57.hermescontrol.ui.chat.tool.ToolResultSummary — a faithful Kotlin port of the desktop app's lib/tool-result-summary.ts (verified against its vitest suite, ported 1:1 to JUnit): - formatToolResultSummary: wrapper unwrap (data/result/output/response/ payload, up to 4 levels), priority-key ordering, array/record block formatting with depth caps, noise filtering (success:true etc.) - extractToolErrorMessage: cycle-safe nested hunt for real error text through wrappers, with placeholder filtering (error:'none' ignored) and stderr deliberately excluded as an error signal Also handles stringified-JSON payloads (re-parse on demand) like the desktop's tryJson/norm layer. Ported contract tests: ToolResultSummaryTest (10 cases). * feat(chat): port desktop per-tool display engine (ToolViewBuilder) Second half of the desktop engine port, in the new ui.chat.tool package: - ToolView: the render model the builder produces — status/title/subtitle/ detail/count/duration/stdout/stderr/exitCode/diff/searchHits/imageUrl — no raw JSON in UI code - ToolViewBuilder: port of the desktop buildToolView (fallback-model): status heuristics (explicit success wins; non-zero exit alone is NOT an error), dynamic titles (Read/Searched/Ran with summarized commands), per-tool subtitle/detail (browser snapshot stats, terminal output preview, memory message, cron job counts), count-label cascade, web_search hit parsing, inline diff + stats, stdout/stderr split, duration formatting, redundant-detail suppression - Mobile-specific tool cases carried into the builder: todo, fact_store, session_search, skills_list/view, skill_manage, process, x_search, vision_analyze, tool_search, image_generate, project_*, read_terminal, computer_use - CommandSummarizer: port of the desktop summarize-command.ts (quote-aware compound split, pipe-tail strip, redirect/env cleanup) - Terminal stdout reads the gateway's canonical merged `output` key with stdout/stderr fallback (mobile terminal_tool contract differs from desktop) Tests: ToolViewBuilderTest (18), CommandSummarizerTest (8), all ported or derived from the desktop suite. Full suite green: 655 tests. --------- Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
1daa03d686
commit
100a82dd4f
7 files changed
+3305
No files matched your search
@@ -0,0 +1,215 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.tool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce a verbose shell command to the "main" command, for display only.
|
||||||
|
*
|
||||||
|
* Ported from the desktop app's `lib/summarize-command.ts`. Agents wrap real
|
||||||
|
* work in plumbing — `cd <dir> && <cmd> 2>&1 | tail -N; echo "x_exit=..."` —
|
||||||
|
* which buries the command the user actually cares about. This peels that
|
||||||
|
* wrapper off using small head-word allowlists instead of one giant regex:
|
||||||
|
*
|
||||||
|
* 1. split into segments on top-level `&&` `||` `;` (quote-aware)
|
||||||
|
* 2. strip trailing pipe tails (`| head`, `| tail`, `| wc`, ...)
|
||||||
|
* 3. clean env var prefixes / redirects
|
||||||
|
* 4. drop setup/banner/status segments
|
||||||
|
*
|
||||||
|
* If one real command survives, show it. If multiple real commands survive,
|
||||||
|
* show a short `first command + N commands` label instead of flooding the row
|
||||||
|
* with every probe. The full command is always still available via Copy.
|
||||||
|
*/
|
||||||
|
object CommandSummarizer {
|
||||||
|
private val SILENT_HEADS =
|
||||||
|
setOf("cd", "pushd", "popd", "export", "set", "unset", "source", ".", "true", "false", ":")
|
||||||
|
private val PIPE_TAIL_HEADS = setOf("head", "tail", "wc", "sort", "uniq")
|
||||||
|
|
||||||
|
private fun basename(head: String): String = head.substringAfterLast('/').ifEmpty { head }
|
||||||
|
|
||||||
|
/** Split on command-chain separators, but NOT pipe. */
|
||||||
|
private fun splitCompoundCommand(input: String): List<String> {
|
||||||
|
val segments = mutableListOf<String>()
|
||||||
|
val buf = StringBuilder()
|
||||||
|
var quote: Char? = null
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < input.length) {
|
||||||
|
val ch = input[i]
|
||||||
|
|
||||||
|
if (quote != null) {
|
||||||
|
buf.append(ch)
|
||||||
|
if (ch == quote && input.getOrNull(i - 1) != '\\') {
|
||||||
|
quote = null
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch == '"' || ch == '\'') {
|
||||||
|
quote = ch
|
||||||
|
buf.append(ch)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val op =
|
||||||
|
when {
|
||||||
|
input.startsWith("&&", i) || input.startsWith("||", i) -> input.substring(i, i + 2)
|
||||||
|
ch == ';' || ch == '\n' -> ch.toString()
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op.isNotEmpty()) {
|
||||||
|
segments += buf.toString()
|
||||||
|
buf.clear()
|
||||||
|
i += op.length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.append(ch)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
segments += buf.toString()
|
||||||
|
|
||||||
|
return segments.map { stripPipeTail(it.trim()) }.filter { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitWords(segment: String): List<String> {
|
||||||
|
val words = mutableListOf<String>()
|
||||||
|
val buf = StringBuilder()
|
||||||
|
var quote: Char? = null
|
||||||
|
|
||||||
|
for ((idx, ch) in segment.withIndex()) {
|
||||||
|
if (quote != null) {
|
||||||
|
buf.append(ch)
|
||||||
|
if (ch == quote && segment.getOrNull(idx - 1) != '\\') {
|
||||||
|
quote = null
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch == '"' || ch == '\'') {
|
||||||
|
quote = ch
|
||||||
|
buf.append(ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch.isWhitespace()) {
|
||||||
|
if (buf.isNotEmpty()) {
|
||||||
|
words += buf.toString()
|
||||||
|
buf.clear()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.append(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buf.isNotEmpty()) {
|
||||||
|
words += buf.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return words
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The command word of a segment, skipping any `FOO=bar` env assignments. */
|
||||||
|
private fun headWord(segment: String): String {
|
||||||
|
val tokens = splitWords(segment)
|
||||||
|
var index = 0
|
||||||
|
|
||||||
|
while (index < tokens.size && Regex("^[A-Za-z_]\\w*=").containsMatchIn(tokens[index])) {
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return basename(tokens.getOrNull(index) ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stripPipeTail(segment: String): String {
|
||||||
|
val words = splitWords(segment)
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
|
||||||
|
for (i in words.indices) {
|
||||||
|
val word = words[i]
|
||||||
|
|
||||||
|
if (word == "|" && basename(words.getOrNull(i + 1) ?: "") in PIPE_TAIL_HEADS) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
out += word
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.joinToString(" ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanSegment(segment: String): String {
|
||||||
|
val words = splitWords(segment)
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < words.size) {
|
||||||
|
val word = words[i]
|
||||||
|
|
||||||
|
// `> file`, `2> file`, `>> file`, `2>&1` — drop the operator + its target.
|
||||||
|
if (Regex("^\\d*(?:>>?|<)$").matches(word)) {
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Regex("^\\d*(?:>&|<&)\\d+$").matches(word)) {
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out += word
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.joinToString(" ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isBoundaryEcho(segment: String): Boolean {
|
||||||
|
val words = splitWords(segment)
|
||||||
|
|
||||||
|
if (basename(words.firstOrNull() ?: "") != "echo") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banner/status echoes are UI plumbing. Do not treat arbitrary
|
||||||
|
// `echo $VALUE` as noise; it may be the command's actual output.
|
||||||
|
val rest = words.drop(1).joinToString(" ")
|
||||||
|
|
||||||
|
return Regex("-{2,}|_exit=|(?:^|\\s|=)\\$[?{]|PIPESTATUS").containsMatchIn(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun summarizeShellCommand(raw: String?): String {
|
||||||
|
val original = (raw ?: "").trim()
|
||||||
|
|
||||||
|
if (original.isEmpty()) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val segments = splitCompoundCommand(original)
|
||||||
|
|
||||||
|
if (segments.size <= 1) {
|
||||||
|
return cleanSegment(original).ifEmpty { original }
|
||||||
|
}
|
||||||
|
|
||||||
|
val core =
|
||||||
|
segments
|
||||||
|
.map { cleanSegment(it) }
|
||||||
|
.filter { segment ->
|
||||||
|
val head = headWord(segment)
|
||||||
|
|
||||||
|
segment.isNotEmpty() && head !in SILENT_HEADS && !isBoundaryEcho(segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (core.isEmpty()) {
|
||||||
|
return original
|
||||||
|
}
|
||||||
|
|
||||||
|
if (core.size == 1) {
|
||||||
|
return core[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return "${core[0]} + ${core.size - 1} ${if (core.size == 2) "command" else "commands"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.tool
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristic JSON → human summary engine for tool results.
|
||||||
|
*
|
||||||
|
* Ported from the Hermes desktop app's `lib/tool-result-summary.ts`
|
||||||
|
* (verified against its vitest suite). Pure functions over a parsed
|
||||||
|
* [JsonElement] — no UI deps, fully unit-testable.
|
||||||
|
*
|
||||||
|
* Two exports:
|
||||||
|
* - [formatToolResultSummary]: best-effort one-line/block human summary of
|
||||||
|
* any tool result payload (wrapper unwrap, priority keys, depth caps).
|
||||||
|
* - [extractToolErrorMessage]: deep hunt for a real error message through
|
||||||
|
* nested wrappers, with placeholder-value filtering.
|
||||||
|
*/
|
||||||
|
object ToolResultSummary {
|
||||||
|
private val WRAPPER_KEYS = listOf("data", "result", "output", "response", "payload")
|
||||||
|
|
||||||
|
private val PRIORITY_KEYS =
|
||||||
|
listOf(
|
||||||
|
"title",
|
||||||
|
"name",
|
||||||
|
"path",
|
||||||
|
"file",
|
||||||
|
"filepath",
|
||||||
|
"url",
|
||||||
|
"href",
|
||||||
|
"link",
|
||||||
|
"status",
|
||||||
|
"id",
|
||||||
|
"message",
|
||||||
|
"summary",
|
||||||
|
"description",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val ERROR_KEYS = listOf("error", "errors", "failure", "exception")
|
||||||
|
|
||||||
|
// 'stderr' deliberately excluded: many CLIs emit informational lines on
|
||||||
|
// stderr (npm progress, git's hint:, gcc's `In file included from`) that
|
||||||
|
// aren't errors. Treating those as error signal flipped tool cards into
|
||||||
|
// destructive styling for healthy commands.
|
||||||
|
private val ERROR_MSG_KEYS = listOf("message", "reason", "detail")
|
||||||
|
private val NON_ERROR_TEXT = setOf("", "0", "false", "none", "null", "nil", "ok", "success", "n/a", "na")
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun normalize(value: String): String = value.lowercase().trim()
|
||||||
|
|
||||||
|
private fun capitalize(value: String): String =
|
||||||
|
value.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||||
|
|
||||||
|
private fun titleCase(key: String): String =
|
||||||
|
key
|
||||||
|
.split(Regex("[_\\-.]+"))
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.joinToString(" ") { capitalize(it) }
|
||||||
|
|
||||||
|
private fun pluralize(
|
||||||
|
n: Int,
|
||||||
|
noun: String,
|
||||||
|
): String = "$n $noun${if (n == 1) "" else "s"}"
|
||||||
|
|
||||||
|
/** Trim + collapse whitespace, truncate with ellipsis. */
|
||||||
|
private fun clipInline(
|
||||||
|
value: String,
|
||||||
|
max: Int = 180,
|
||||||
|
): String {
|
||||||
|
val c = value.replace(Regex("\\s+"), " ").trim()
|
||||||
|
|
||||||
|
return if (c.length > max) "${c.take(max - 1)}…" else c
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First N lines + char cap, with a trailing ellipsis when clipped. */
|
||||||
|
private fun clipBlock(
|
||||||
|
value: String,
|
||||||
|
maxChars: Int = 1800,
|
||||||
|
maxLines: Int = 18,
|
||||||
|
): String {
|
||||||
|
val t = value.trim()
|
||||||
|
|
||||||
|
if (t.isEmpty()) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val lines = t.split("\n")
|
||||||
|
var text = lines.take(maxLines).joinToString("\n")
|
||||||
|
val clipped = lines.size > maxLines || text.length > maxChars
|
||||||
|
|
||||||
|
if (text.length > maxChars) {
|
||||||
|
text = text.take(maxChars - 1).trimEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (clipped && !text.endsWith("…")) "$text…" else text
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firstString(
|
||||||
|
record: JsonObject,
|
||||||
|
keys: List<String>,
|
||||||
|
): String {
|
||||||
|
for (k in keys) {
|
||||||
|
val v = record[k] ?: continue
|
||||||
|
if (v is JsonPrimitive && v.isString && v.content.trim().isNotEmpty()) {
|
||||||
|
return v.content.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-parse stringified JSON payloads; everything else passes through. */
|
||||||
|
private fun norm(value: JsonElement?): JsonElement? {
|
||||||
|
val v = value ?: return null
|
||||||
|
if (v is JsonNull) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (v !is JsonPrimitive || !v.isString) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
val t = v.content.trim()
|
||||||
|
if (t.isEmpty() || !(t.startsWith("{") || t.startsWith("[") || t.startsWith("\""))) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
Json.parseToJsonElement(t)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isWrapperKey(k: String): Boolean = k in WRAPPER_KEYS
|
||||||
|
|
||||||
|
/** `success: true` / `ok: true` are noise — never rendered. */
|
||||||
|
private fun skipField(
|
||||||
|
k: String,
|
||||||
|
v: JsonElement?,
|
||||||
|
): Boolean {
|
||||||
|
if (isWrapperKey(k)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((k == "success" || k == "ok") && v is JsonPrimitive && !v.isString && v.content == "true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scalarString(v: JsonElement?): String? {
|
||||||
|
if (v is JsonPrimitive && v.isString) {
|
||||||
|
return v.content
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v is JsonPrimitive && !v.isString) {
|
||||||
|
val c = v.content
|
||||||
|
if (c == "true" || c == "false" || c.toDoubleOrNull() != null) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun summarizeScalar(v: JsonElement?): String {
|
||||||
|
if (v is JsonPrimitive && v.isString) {
|
||||||
|
return clipInline(v.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v is JsonPrimitive && !v.isString) {
|
||||||
|
return v.content
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun orderedKeys(keys: List<String>): List<String> {
|
||||||
|
val priority = PRIORITY_KEYS.filter { it in keys }
|
||||||
|
val rest = keys.filter { it !in PRIORITY_KEYS }
|
||||||
|
|
||||||
|
return priority + rest
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun summarizeRecordInline(
|
||||||
|
record: JsonObject,
|
||||||
|
depth: Int,
|
||||||
|
): String {
|
||||||
|
if (depth > 3) {
|
||||||
|
return pluralize(record.keys.size, "field")
|
||||||
|
}
|
||||||
|
|
||||||
|
val title =
|
||||||
|
firstString(record, listOf("title", "name", "path", "file", "filepath", "url", "href", "link", "id"))
|
||||||
|
val status = firstString(record, listOf("status", "category", "type"))
|
||||||
|
val message = firstString(record, listOf("snippet", "summary", "description", "message"))
|
||||||
|
|
||||||
|
if (title.isNotEmpty() && status.isNotEmpty()) {
|
||||||
|
return "${clipInline(title, 110)} (${clipInline(status, 54)})"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title.isNotEmpty() && message.isNotEmpty() && title != message) {
|
||||||
|
return "${clipInline(title, 90)} - ${clipInline(message, 84)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title.isNotEmpty()) {
|
||||||
|
return clipInline(title, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pairs =
|
||||||
|
orderedKeys(record.keys.toList())
|
||||||
|
.filter { k -> !skipField(k, record[k]) }
|
||||||
|
.mapNotNull { k ->
|
||||||
|
val s = summarizeScalar(record[k])
|
||||||
|
if (s.isEmpty()) null else "${titleCase(k)}: $s"
|
||||||
|
}
|
||||||
|
.take(2)
|
||||||
|
|
||||||
|
return if (pairs.isNotEmpty()) pairs.joinToString(" · ") else pluralize(record.keys.size, "field")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun summarizeListItem(
|
||||||
|
item: JsonElement?,
|
||||||
|
depth: Int,
|
||||||
|
): String {
|
||||||
|
val v = norm(item)
|
||||||
|
if (v is JsonNull || v == null) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val scalar = scalarString(v)
|
||||||
|
if (scalar != null) {
|
||||||
|
return clipInline(scalar)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v is JsonArray) {
|
||||||
|
return pluralize(v.size, "item")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v is JsonObject) {
|
||||||
|
return summarizeRecordInline(v, depth + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clipInline(v.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatFieldValue(
|
||||||
|
value: JsonElement?,
|
||||||
|
depth: Int,
|
||||||
|
): String {
|
||||||
|
val v = norm(value)
|
||||||
|
val scalar = summarizeScalar(v)
|
||||||
|
|
||||||
|
if (scalar.isNotEmpty()) {
|
||||||
|
return scalar
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v == null || v is JsonNull) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v is JsonArray) {
|
||||||
|
if (v.isEmpty()) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val scalars = v.mapNotNull { summarizeScalar(it).takeIf { s -> s.isNotEmpty() } }
|
||||||
|
|
||||||
|
if (scalars.size == v.size && v.size <= 4) {
|
||||||
|
return clipInline(scalars.joinToString(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
val first = summarizeListItem(v.firstOrNull(), depth + 1)
|
||||||
|
|
||||||
|
return if (first.isNotEmpty()) "${pluralize(v.size, "item")} ($first)" else pluralize(v.size, "item")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v is JsonObject) {
|
||||||
|
return summarizeRecordInline(v, depth + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clipInline(v.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Returned N items" / "0 items" / "Returned an empty object" are all
|
||||||
|
* noise — better to render nothing and let the title carry the signal.
|
||||||
|
*/
|
||||||
|
private fun formatArraySummary(
|
||||||
|
value: JsonArray,
|
||||||
|
depth: Int,
|
||||||
|
): String {
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val max = 6
|
||||||
|
val lines =
|
||||||
|
value
|
||||||
|
.take(max)
|
||||||
|
.mapNotNull { item -> summarizeListItem(item, depth + 1).takeIf { it.isNotEmpty() } }
|
||||||
|
.map { "- $it" }
|
||||||
|
.toMutableList()
|
||||||
|
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.size > max) {
|
||||||
|
val remaining = value.size - max
|
||||||
|
lines += "- … $remaining more ${if (remaining == 1) "item" else "items"}"
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatRecordSummary(
|
||||||
|
record: JsonObject,
|
||||||
|
depth: Int,
|
||||||
|
): String {
|
||||||
|
val keys = record.keys.toList()
|
||||||
|
|
||||||
|
if (keys.isEmpty()) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth <= 2) {
|
||||||
|
val direct = firstString(record, listOf("message", "summary", "description", "preview", "text", "content"))
|
||||||
|
val meaningful = keys.filter { k -> !skipField(k, record[k]) && !isWrapperKey(k) }
|
||||||
|
|
||||||
|
if (direct.isNotEmpty() && meaningful.size <= 1) {
|
||||||
|
return clipBlock(direct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val candidates = orderedKeys(keys).filter { k -> !skipField(k, record[k]) }
|
||||||
|
val max = 8
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
|
||||||
|
for (k in candidates) {
|
||||||
|
val v = formatFieldValue(record[k], depth + 1)
|
||||||
|
|
||||||
|
if (v.isEmpty()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lines += "- ${titleCase(k)}: $v"
|
||||||
|
|
||||||
|
if (lines.size >= max) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.size > lines.size) {
|
||||||
|
val remaining = candidates.size - lines.size
|
||||||
|
lines += "- … ${pluralize(remaining, "field")}"
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatSummaryValue(
|
||||||
|
value: JsonElement?,
|
||||||
|
depth: Int,
|
||||||
|
): String {
|
||||||
|
if (depth > 4) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val v = norm(value)
|
||||||
|
|
||||||
|
if (v is JsonPrimitive && v.isString) {
|
||||||
|
return clipBlock(v.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v is JsonPrimitive && !v.isString) {
|
||||||
|
return v.content
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v == null || v is JsonNull) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v is JsonArray) {
|
||||||
|
return formatArraySummary(v, depth + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v is JsonObject) {
|
||||||
|
return formatRecordSummary(v, depth + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clipInline(v.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Peel up to 4 wrapper layers (`data` / `result` / `output` / …). */
|
||||||
|
private fun unwrapPayload(value: JsonElement?): JsonElement? {
|
||||||
|
var cur: JsonElement? = norm(value)
|
||||||
|
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
val record = cur as? JsonObject ?: return cur
|
||||||
|
val key = WRAPPER_KEYS.firstOrNull { k -> record[k] != null && record[k] !is JsonNull } ?: return record
|
||||||
|
|
||||||
|
cur = norm(record[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── public API ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun formatToolResultSummary(value: JsonElement?): String {
|
||||||
|
val unwrapped = formatSummaryValue(unwrapPayload(value), 0)
|
||||||
|
|
||||||
|
return if (unwrapped.isNotEmpty()) unwrapped else formatSummaryValue(value, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── error extraction ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun hasMeaningfulErrorValue(value: JsonElement?): Boolean {
|
||||||
|
val v = norm(value)
|
||||||
|
|
||||||
|
if (v == null || v is JsonNull) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v is JsonPrimitive) {
|
||||||
|
if (v.isString) {
|
||||||
|
return normalize(v.content) !in NON_ERROR_TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.content != "0" && v.content != "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v is JsonArray) {
|
||||||
|
return v.any { hasMeaningfulErrorValue(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v is JsonObject) {
|
||||||
|
return v.keys.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasErrorSignal(record: JsonObject): Boolean {
|
||||||
|
val status = (record["status"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
(record["success"] as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true ||
|
||||||
|
(record["ok"] as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true ||
|
||||||
|
Regex(
|
||||||
|
"\\b(error|failed|failure|fatal|exception)\\b",
|
||||||
|
RegexOption.IGNORE_CASE,
|
||||||
|
).containsMatchIn(status) ||
|
||||||
|
ERROR_KEYS.any { k -> hasMeaningfulErrorValue(record[k]) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun valueErrorText(value: JsonElement?): String {
|
||||||
|
val v = norm(value)
|
||||||
|
|
||||||
|
if (v is JsonPrimitive && v.isString) {
|
||||||
|
return if (hasMeaningfulErrorValue(v)) clipBlock(v.content, 700, 12) else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v is JsonArray) {
|
||||||
|
return clipBlock(
|
||||||
|
v.mapNotNull {
|
||||||
|
valueErrorText(it).takeIf {
|
||||||
|
s ->
|
||||||
|
s.isNotEmpty()
|
||||||
|
}
|
||||||
|
}.take(3).joinToString("; "),
|
||||||
|
700,
|
||||||
|
12,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v is JsonObject) {
|
||||||
|
val direct = firstString(v, ERROR_MSG_KEYS)
|
||||||
|
|
||||||
|
if (direct.isNotEmpty()) {
|
||||||
|
return clipBlock(direct, 700, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findNestedError(
|
||||||
|
value: JsonElement?,
|
||||||
|
depth: Int,
|
||||||
|
seen: MutableSet<JsonElement>,
|
||||||
|
): String {
|
||||||
|
if (depth > 5) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val v = norm(value) ?: return ""
|
||||||
|
|
||||||
|
if (v is JsonNull) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v in seen) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
seen.add(v)
|
||||||
|
|
||||||
|
if (v is JsonArray) {
|
||||||
|
for (item in v) {
|
||||||
|
val nested = findNestedError(item, depth + 1, seen)
|
||||||
|
|
||||||
|
if (nested.isNotEmpty()) {
|
||||||
|
return nested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v !is JsonObject) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for (k in ERROR_KEYS) {
|
||||||
|
if (!hasMeaningfulErrorValue(v[k])) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val text = valueErrorText(v[k])
|
||||||
|
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasErrorSignal(v)) {
|
||||||
|
val direct = firstString(v, ERROR_MSG_KEYS)
|
||||||
|
|
||||||
|
if (direct.isNotEmpty()) {
|
||||||
|
return clipBlock(direct, 700, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (k in ERROR_KEYS + WRAPPER_KEYS + listOf("details", "meta")) {
|
||||||
|
val nested = findNestedError(v[k], depth + 1, seen)
|
||||||
|
|
||||||
|
if (nested.isNotEmpty()) {
|
||||||
|
return nested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun extractToolErrorMessage(value: JsonElement?): String = findNestedError(value, 0, mutableSetOf())
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.tool
|
||||||
|
|
||||||
|
/** Render state of a tool call row. */
|
||||||
|
enum class ToolViewStatus {
|
||||||
|
RUNNING,
|
||||||
|
SUCCESS,
|
||||||
|
ERROR,
|
||||||
|
WARNING,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SearchHit(
|
||||||
|
val title: String,
|
||||||
|
val url: String,
|
||||||
|
val snippet: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DiffStats(
|
||||||
|
val added: Int,
|
||||||
|
val removed: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full display model for one tool call, produced by [ToolViewBuilder].
|
||||||
|
*
|
||||||
|
* Ported shape from the desktop app's `ToolView` (fallback-model): the
|
||||||
|
* renderer reads only these fields — no raw JSON spelunking in UI code.
|
||||||
|
*/
|
||||||
|
data class ToolView(
|
||||||
|
val status: ToolViewStatus,
|
||||||
|
/** One-line headline: "Read /tmp/x", "Ran sleep 10 + 2 commands". */
|
||||||
|
val title: String,
|
||||||
|
/** Secondary line naming what was acted on (path, hostname, query…). */
|
||||||
|
val subtitle: String = "",
|
||||||
|
/** Expanded body text. */
|
||||||
|
val detail: String = "",
|
||||||
|
/** Optional section label above [detail] (e.g. "Search results"). */
|
||||||
|
val detailLabel: String? = null,
|
||||||
|
/** Compact count, e.g. "3 results". */
|
||||||
|
val countLabel: String? = null,
|
||||||
|
/** Human duration, e.g. "1.2s". */
|
||||||
|
val durationLabel: String? = null,
|
||||||
|
/** Terminal tools: stdout stream when the backend split streams. */
|
||||||
|
val stdout: String? = null,
|
||||||
|
/** Terminal tools: stderr stream when the backend split streams. */
|
||||||
|
val stderr: String? = null,
|
||||||
|
/** Terminal tools: numeric exit code. */
|
||||||
|
val exitCode: Int? = null,
|
||||||
|
/** Terminal tools: the command that actually ran (for the `$` line). */
|
||||||
|
val terminalCommand: String? = null,
|
||||||
|
/** File-edit tools: the diff to render. */
|
||||||
|
val inlineDiff: String? = null,
|
||||||
|
/** File-edit tools: the file the diff applies to. */
|
||||||
|
val diffPath: String? = null,
|
||||||
|
val diffStats: DiffStats? = null,
|
||||||
|
/** Image-producing tools: renderable URL (data: or http(s) image). */
|
||||||
|
val imageUrl: String? = null,
|
||||||
|
/** web_search: parsed result rows. */
|
||||||
|
val searchHits: List<SearchHit>? = null,
|
||||||
|
val searchQuery: String? = null,
|
||||||
|
/** Detected error text (destructive styling in the view). */
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,71 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.tool
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CommandSummarizerTest {
|
||||||
|
@Test
|
||||||
|
fun `simple command passes through`() {
|
||||||
|
assertEquals("ls -la", CommandSummarizer.summarizeShellCommand("ls -la"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `plumbing wrapper peels to the real command`() {
|
||||||
|
val summarized =
|
||||||
|
CommandSummarizer.summarizeShellCommand(
|
||||||
|
"""cd /tmp && sleep 70 2>&1 | tail -5; echo "x_exit=${'$'}{PIPESTATUS[0]}"""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("sleep 70", summarized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multiple real commands become first plus count`() {
|
||||||
|
// Faithful to the TS port: the FIRST surviving segment leads the
|
||||||
|
// count line — echo is not a silent head, so it leads here.
|
||||||
|
assertEquals("echo hi + 2 commands", CommandSummarizer.summarizeShellCommand("echo hi && sleep 2 && ls"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `redirects are cleaned`() {
|
||||||
|
// Env-prefix removal happens in headWord (classification only), so
|
||||||
|
// the output keeps FOO=bar — mirrors the TS behavior exactly.
|
||||||
|
assertEquals(
|
||||||
|
"FOO=bar python run.py",
|
||||||
|
CommandSummarizer.summarizeShellCommand("FOO=bar python run.py > /tmp/out.log 2>&1"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `single-segment commands keep their pipe tail`() {
|
||||||
|
// Faithful to the TS port: the single-segment path re-cleans the
|
||||||
|
// ORIGINAL command (redirects only, no pipe handling) — pipe-tail
|
||||||
|
// stripping applies in the multi-segment path below.
|
||||||
|
assertEquals(
|
||||||
|
"grep foo bar.txt | tail -20",
|
||||||
|
CommandSummarizer.summarizeShellCommand("grep foo bar.txt | tail -20"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multi-segment commands strip pipe tails`() {
|
||||||
|
assertEquals(
|
||||||
|
"grep foo bar.txt + 1 command",
|
||||||
|
CommandSummarizer.summarizeShellCommand("cd /tmp && grep foo bar.txt | tail -20 && echo done"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `quote-aware compound split keeps echo arguments intact`() {
|
||||||
|
// The `&&` inside quotes is not a separator, so this is two segments
|
||||||
|
// and echo (not silent) leads the count line.
|
||||||
|
val summarized = CommandSummarizer.summarizeShellCommand("""echo "a && b" && git status""")
|
||||||
|
|
||||||
|
assertEquals("""echo "a && b" + 1 command""", summarized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty input stays empty`() {
|
||||||
|
assertEquals("", CommandSummarizer.summarizeShellCommand(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.tool
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ported contract tests from the desktop app's
|
||||||
|
* `lib/tool-result-summary.test.ts` (vitest) — the engine must satisfy the
|
||||||
|
* same cases here.
|
||||||
|
*/
|
||||||
|
class ToolResultSummaryTest {
|
||||||
|
private fun parse(json: String) = Json.parseToJsonElement(json)
|
||||||
|
|
||||||
|
// ── formatToolResultSummary ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unwraps wrapper payloads into structured key-value lines`() {
|
||||||
|
val summary =
|
||||||
|
ToolResultSummary.formatToolResultSummary(
|
||||||
|
parse(
|
||||||
|
"""{"success":true,"result":{"data":{"path":"/tmp/demo.txt","status":"ok",""" +
|
||||||
|
""""lines_written":12,"checksum":"abc123"}}}""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(summary.contains("- Path: /tmp/demo.txt"))
|
||||||
|
assertTrue(summary.contains("- Status: ok"))
|
||||||
|
assertTrue(summary.contains("- Lines Written: 12"))
|
||||||
|
assertFalse(summary.contains("\"path\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `summarizes object arrays as readable list items`() {
|
||||||
|
val json =
|
||||||
|
"""[
|
||||||
|
{"title":"First result","snippet":"alpha preview text"},
|
||||||
|
{"title":"Second result","status":"cached"},
|
||||||
|
{"title":"Third result","summary":"more details"},
|
||||||
|
{"title":"Fourth result","summary":"line 4"},
|
||||||
|
{"title":"Fifth result","summary":"line 5"},
|
||||||
|
{"title":"Sixth result","summary":"line 6"},
|
||||||
|
{"title":"Seventh result","summary":"line 7"}
|
||||||
|
]"""
|
||||||
|
val summary = ToolResultSummary.formatToolResultSummary(parse(json))
|
||||||
|
|
||||||
|
assertTrue("got:\n$summary", summary.contains("- First result - alpha preview text"))
|
||||||
|
assertTrue(summary.contains("- Second result (cached)"))
|
||||||
|
assertTrue("got:\n$summary", summary.contains("- … 1 more item"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `truncates long field values for compact display`() {
|
||||||
|
val summary =
|
||||||
|
ToolResultSummary.formatToolResultSummary(
|
||||||
|
parse("""{"message":"ok","details":"prefix ${"x".repeat(500)}"}"""),
|
||||||
|
)
|
||||||
|
val detailsLine = summary.split("\n").firstOrNull { it.startsWith("- Details:") }
|
||||||
|
|
||||||
|
assertTrue(detailsLine != null)
|
||||||
|
assertTrue(detailsLine!!.length < 230)
|
||||||
|
assertTrue(detailsLine.contains("…"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formats stringified json payloads without raw dumps`() {
|
||||||
|
val summary =
|
||||||
|
ToolResultSummary.formatToolResultSummary(
|
||||||
|
JsonPrimitive("""{"data":{"title":"Build report","completed":true}}"""),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(summary.contains("- Title: Build report"))
|
||||||
|
assertTrue(summary.contains("- Completed: true"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── extractToolErrorMessage ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `finds nested error messages through wrappers`() {
|
||||||
|
val error =
|
||||||
|
ToolResultSummary.extractToolErrorMessage(
|
||||||
|
parse(
|
||||||
|
"""{"success":false,"result":{"output":{"error":{"message":""" +
|
||||||
|
""""Permission denied writing /tmp/demo.txt"}}}}""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Permission denied writing /tmp/demo.txt", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not treat successful payload messages as errors`() {
|
||||||
|
val error =
|
||||||
|
ToolResultSummary.extractToolErrorMessage(
|
||||||
|
parse("""{"success":true,"message":"Completed successfully","data":{"count":3}}"""),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ignores placeholder error fields in successful payloads`() {
|
||||||
|
val error =
|
||||||
|
ToolResultSummary.extractToolErrorMessage(
|
||||||
|
parse("""{"success":true,"data":{"error":"none","status":"ok"}}"""),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── extra edge cases beyond the desktop suite ───────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty array renders nothing instead of noise`() {
|
||||||
|
val summary = ToolResultSummary.formatToolResultSummary(parse("""{"results":[]}"""))
|
||||||
|
|
||||||
|
assertEquals("", summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unwraps up to four wrapper levels`() {
|
||||||
|
val summary =
|
||||||
|
ToolResultSummary.formatToolResultSummary(
|
||||||
|
parse(
|
||||||
|
"""{"data":{"result":{"output":{"response":{"title":"deep","count":2}}}}}""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(summary.contains("- Title: deep"))
|
||||||
|
assertTrue(summary.contains("- Count: 2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scalar results pass through`() {
|
||||||
|
assertEquals("hello", ToolResultSummary.formatToolResultSummary(JsonPrimitive("hello")))
|
||||||
|
assertEquals("42", ToolResultSummary.formatToolResultSummary(JsonPrimitive(42)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.tool
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
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
|
||||||
|
|
||||||
|
class ToolViewBuilderTest {
|
||||||
|
private fun parse(json: String): JsonObject? = Json.parseToJsonElement(json) as? JsonObject
|
||||||
|
|
||||||
|
private fun build(
|
||||||
|
tool: String,
|
||||||
|
args: String,
|
||||||
|
result: String,
|
||||||
|
isError: Boolean = false,
|
||||||
|
running: Boolean = false,
|
||||||
|
) = ToolViewBuilder.build(tool, parse(args), parse(result), isError, running)
|
||||||
|
|
||||||
|
// ── status heuristics ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `terminal success with exit zero is success`() {
|
||||||
|
val view = build("terminal", """{"command":"ls -la"}""", """{"output":"wiring.tsx","exit_code":0}""")
|
||||||
|
|
||||||
|
assertEquals(ToolViewStatus.SUCCESS, view.status)
|
||||||
|
assertNull(view.error)
|
||||||
|
assertEquals("wiring.tsx", view.stdout)
|
||||||
|
assertEquals(0, view.exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `terminal non-zero exit without output is an error`() {
|
||||||
|
val view = build("terminal", """{"command":"exit 1"}""", """{"exit_code":1}""")
|
||||||
|
|
||||||
|
assertEquals(ToolViewStatus.ERROR, view.status)
|
||||||
|
assertEquals("Command failed with exit code 1.", view.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `terminal non-zero exit with output is not an error`() {
|
||||||
|
val view = build("terminal", """{"command":"grep foo bar"}""", """{"exit_code":1,"output":"nothing"}""")
|
||||||
|
|
||||||
|
assertEquals(ToolViewStatus.SUCCESS, view.status)
|
||||||
|
assertNull(view.error)
|
||||||
|
assertEquals("nothing", view.stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `explicit success wins over stale isError envelope`() {
|
||||||
|
val view = build("memory", """{"action":"add","target":"user"}""", """{"success":true}""", isError = true)
|
||||||
|
|
||||||
|
assertEquals(ToolViewStatus.SUCCESS, view.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejected memory write is a warning not an error`() {
|
||||||
|
val view = build("memory", """{"action":"add"}""", """{"success":false,"message":"over budget"}""")
|
||||||
|
|
||||||
|
assertEquals(ToolViewStatus.WARNING, view.status)
|
||||||
|
assertEquals("over budget", view.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `running tool gets running status and pending title`() {
|
||||||
|
val view = build("terminal", """{"command":"sleep 10"}""", """{}""", running = true)
|
||||||
|
|
||||||
|
assertEquals(ToolViewStatus.RUNNING, view.status)
|
||||||
|
assertTrue(view.title.startsWith("Running"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── titles / subtitles ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `read_file title names the target with line range`() {
|
||||||
|
val view =
|
||||||
|
build("read_file", """{"path":"/repo/src/a.kt","offset":10,"limit":5}""", """{"content":"10|a\n11|b"}""")
|
||||||
|
|
||||||
|
assertEquals("Read a.kt L10-14", view.title)
|
||||||
|
assertEquals("/repo/src/a.kt", view.subtitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `terminal title uses summarized command`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"terminal",
|
||||||
|
"""{"command":"cd /tmp && sleep 70 2>&1 | tail -5; echo \"x_exit=${'$'}{PIPESTATUS[0]}\""}""",
|
||||||
|
"""{"output":"done"}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(ToolViewStatus.SUCCESS, view.status)
|
||||||
|
assertTrue(view.title, view.title.startsWith("Ran "))
|
||||||
|
assertTrue(view.title, view.title.contains("sleep 70"))
|
||||||
|
assertFalse(view.title.contains("tail"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `web_search parses hits and count`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"web_search",
|
||||||
|
"""{"search_term":"cats"}""",
|
||||||
|
"""{"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"},
|
||||||
|
{"title":"Cat Videos","url":"https://cats.example/3","description":"videos"}
|
||||||
|
]}}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Searched \"cats\"", view.title)
|
||||||
|
assertEquals("3 results", view.countLabel)
|
||||||
|
assertEquals(3, view.searchHits?.size)
|
||||||
|
assertEquals("Cat Facts", view.searchHits?.first()?.title)
|
||||||
|
assertEquals("cats", view.searchQuery)
|
||||||
|
assertEquals("Search results", view.detailLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `patch extracts inline diff with stats`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"patch",
|
||||||
|
"""{"path":"/repo/src/wiring.tsx"}""",
|
||||||
|
"""{"inline_diff":"--- a/wiring.tsx\n+++ b/wiring.tsx\n@@ -1,1 +1,1 @@\n-old\n+new"}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(view.inlineDiff)
|
||||||
|
assertEquals("/repo/src/wiring.tsx", view.diffPath)
|
||||||
|
assertEquals(1, view.diffStats?.added)
|
||||||
|
assertEquals(1, view.diffStats?.removed)
|
||||||
|
assertEquals("wiring.tsx", view.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `browser_snapshot subtitle summarizes accessibility stats`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"browser_snapshot",
|
||||||
|
"""{}""",
|
||||||
|
"""{"snapshot":"button \"Save\"\nbutton \"Cancel\"\nlink \"Docs\"\ntextbox \"Name\""}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(view.subtitle, view.subtitle.contains("2 buttons"))
|
||||||
|
assertTrue(view.subtitle.contains("1 links"))
|
||||||
|
assertTrue(view.subtitle.contains("Top controls: Save, Cancel"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mobile-specific tools ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fact_store added fact gets clean summary and detail`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"fact_store",
|
||||||
|
"""{"action":"add"}""",
|
||||||
|
"""{"status":"added","fact_id":42}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Fact added (ID: 42)", view.subtitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fact_store list renders facts with trust scores`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"fact_store",
|
||||||
|
"""{"action":"search","query":"x"}""",
|
||||||
|
"""{"count":1,"results":[{"fact_id":7,"content":"user likes tea",""" +
|
||||||
|
""""category":"user_pref","trust_score":0.8}]}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("1 facts (search: x)", view.subtitle)
|
||||||
|
assertTrue(view.detail, view.detail.contains("#7 user likes tea"))
|
||||||
|
assertTrue(view.detail.contains("trust: 0.80"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `todo gets count subtitle and markers`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"todo",
|
||||||
|
"""{"action":"create"}""",
|
||||||
|
"""{"summary":{"total":2,"pending":1,"completed":1},"todos":[
|
||||||
|
{"id":"1","content":"one","status":"pending"},
|
||||||
|
{"id":"2","content":"two","status":"completed"}
|
||||||
|
]}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("2 items (1 pending, 1 completed)", view.subtitle)
|
||||||
|
assertTrue(view.detail, view.detail.contains("[ ] 1. one"))
|
||||||
|
assertTrue(view.detail.contains("[x] 2. two"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cronjob list subtitle counts jobs`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"cronjob",
|
||||||
|
"""{"action":"list"}""",
|
||||||
|
"""{"jobs":[{"name":"daily","schedule":"0 9 * * *"},{"name":"hourly","schedule":"0 * * * *"}]}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("2 cron jobs", view.subtitle)
|
||||||
|
assertTrue(view.detail, view.detail.contains("- daily · 0 9 * * *"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `session_search discover mode`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"session_search",
|
||||||
|
"""{"query":"auth"}""",
|
||||||
|
"""{"mode":"discover","count":2,"results":[{"title":"Auth refactor",""" +
|
||||||
|
""""when":"2026-07-01","snippet":"..."}]}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(view.subtitle, view.subtitle.startsWith("2 sessions"))
|
||||||
|
assertTrue(view.detail, view.detail.contains("Auth refactor"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `read_terminal custom detail`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"read_terminal",
|
||||||
|
"""{"session_id":"s1"}""",
|
||||||
|
"""{"total_lines":50,"start":1,"end":20,"text":"line one","cursor_row":5}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Lines 1-20 of 50, cursor at row 5", view.subtitle)
|
||||||
|
assertTrue(view.detail, view.detail.contains("━━━ Terminal ━━━"))
|
||||||
|
assertTrue(view.detail.contains("line one"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── unknown tools fall back to the generic engine ────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown tool gets a heuristic summary not raw json`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"weird_tool",
|
||||||
|
"""{}""",
|
||||||
|
"""{"result":{"data":{"title":"Build report","completed":true}}}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Weird Tool", view.title)
|
||||||
|
assertTrue(view.detail, view.detail.contains("Title: Build report"))
|
||||||
|
assertTrue(view.detail.contains("Completed: true"))
|
||||||
|
assertFalse(view.detail.contains("\"data\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown tool error surfaces through the generic engine`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"weird_tool",
|
||||||
|
"""{}""",
|
||||||
|
"""{"success":false,"error":{"message":"Permission denied"}}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(ToolViewStatus.ERROR, view.status)
|
||||||
|
assertTrue(view.error?.contains("Permission denied") == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user