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,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