feat(chat): rewire tool bubbles onto the desktop engine (stage 3) (#765)
* feat(chat): rewire tool bubbles onto the desktop engine (stage 3)
parseToolOutput is now a thin adapter: it normalizes the payload shape
(args/result sub-objects, legacy top-level fallback, name resolution)
and delegates all per-tool formatting to ToolViewBuilder.
- ToolResultParser.kt: 1325 lines of bespoke formatters → 45-line adapter
- ParsedToolData deleted; ToolBubble renders ToolView directly:
collapsed = emoji + engine title (+countLabel) + subtitle line;
expanded = error → $ command / stdout / stderr / exit code →
DiffViewCard → search hits → detail → duration footer
- Gson-compat shims, formatTodoToolOutput, toIntOrNull and all
per-tool formatters died with the old parser (engine is idiomatic kotlinx)
- Kept mobile-only UI: progress preview, SecurityRiskChip, emoji
prefixes, raw-JSON toggle, DiffViewCard, copy
- ToolViewBuilder fixes found during the rewire: numberValue now accepts
numeric strings (TS Number() semantics — gateway ships exit_code as
string/float), memory title keeps its target suffix
ToolBubbleParsingTest rewritten as adapter-contract tests (14 cases);
per-tool behavior lives in ToolViewBuilderTest. Verified: ktlint +
full testDebugUnitTest.
* fix(chat): dedupe tool name in collapsed bubble summary
The header row already shows the tool name, but the collapsed summary
showed emoji + engine title — and for ~11 tools the engine title is just
the generic humanized name (fact_store -> "Fact Store", todo -> "Todo"),
so the bubble displayed the tool identity twice.
composeToolSummaryLines now drops the title when it equals the generic
humanized name and merges the subtitle into the emoji line instead
("🧠 Fact added (ID: 5)"), restoring the pre-engine one-line style.
Descriptive titles ("Searched \"cats\"", "Ran ls", "Read a.kt L10-14")
stay on line one with the subtitle on line two. A lone emoji with no
subtitle renders nothing.
ToolViewBuilder.genericTitleFor exposes the generic title for the
comparison. 9 new unit tests.
---------
Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
100a82dd4f
commit
982f8aec4e
6 files changed
+507
-1766
No files matched your search
@@ -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<String, String> = emptyMap(),
|
||||
val isRunning: Boolean = false,
|
||||
val diffOutput: String? = null,
|
||||
val diffPath: String? = null,
|
||||
)
|
||||
@@ -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<String, String?>? {
|
||||
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,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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" -> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user