feat(chat): upgrade tool rendering, add missing renderers, and clamp output budget (#1086)
This commit is contained in:
11 files changed
+640
-9
No files matched your search
@@ -16,12 +16,15 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Build
|
import androidx.compose.material.icons.filled.Build
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
@@ -45,7 +48,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -58,6 +63,7 @@ import androidx.compose.ui.text.style.TextDecoration
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
@@ -84,6 +90,7 @@ internal fun composeToolSummaryLines(
|
|||||||
): Pair<String, String?>? {
|
): Pair<String, String?>? {
|
||||||
val titleIsGeneric = view.title.equals(ToolViewBuilder.genericTitleFor(toolName), ignoreCase = true)
|
val titleIsGeneric = view.title.equals(ToolViewBuilder.genericTitleFor(toolName), ignoreCase = true)
|
||||||
val subtitle = view.subtitle.takeIf { it.isNotBlank() && it != view.title }
|
val subtitle = view.subtitle.takeIf { it.isNotBlank() && it != view.title }
|
||||||
|
val statsSuffix = view.diffStats?.let { " (+${it.added}/-${it.removed})" } ?: ""
|
||||||
|
|
||||||
val firstLine: String
|
val firstLine: String
|
||||||
val secondLine: String?
|
val secondLine: String?
|
||||||
@@ -93,6 +100,7 @@ internal fun composeToolSummaryLines(
|
|||||||
if (subtitle != null) {
|
if (subtitle != null) {
|
||||||
append(subtitle)
|
append(subtitle)
|
||||||
}
|
}
|
||||||
|
append(statsSuffix)
|
||||||
view.countLabel?.let { append(" ($it)") }
|
view.countLabel?.let { append(" ($it)") }
|
||||||
}
|
}
|
||||||
secondLine = null
|
secondLine = null
|
||||||
@@ -100,6 +108,7 @@ internal fun composeToolSummaryLines(
|
|||||||
firstLine =
|
firstLine =
|
||||||
buildString {
|
buildString {
|
||||||
append(view.title)
|
append(view.title)
|
||||||
|
append(statsSuffix)
|
||||||
view.countLabel?.let { append(" ($it)") }
|
view.countLabel?.let { append(" ($it)") }
|
||||||
}
|
}
|
||||||
secondLine = subtitle
|
secondLine = subtitle
|
||||||
@@ -233,6 +242,11 @@ internal fun ToolBubble(
|
|||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 240.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.bodySmall.copy(
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
color = contentColor.copy(alpha = 0.8f),
|
color = contentColor.copy(alpha = 0.8f),
|
||||||
@@ -301,6 +315,11 @@ internal fun ToolBubble(
|
|||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 240.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.bodySmall.copy(
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
color = contentColor.copy(alpha = 0.8f),
|
color = contentColor.copy(alpha = 0.8f),
|
||||||
@@ -383,6 +402,11 @@ private fun ExpandedToolContent(
|
|||||||
view.stdout?.let {
|
view.stdout?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 240.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.bodySmall.copy(
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
color = contentColor.copy(alpha = 0.9f),
|
color = contentColor.copy(alpha = 0.9f),
|
||||||
@@ -394,6 +418,11 @@ private fun ExpandedToolContent(
|
|||||||
view.stderr?.let {
|
view.stderr?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 240.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.bodySmall.copy(
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
color = contentColor.copy(alpha = 0.6f),
|
color = contentColor.copy(alpha = 0.6f),
|
||||||
@@ -408,13 +437,27 @@ private fun ExpandedToolContent(
|
|||||||
text = stringResource(R.string.chat_tool_exit_code, code),
|
text = stringResource(R.string.chat_tool_exit_code, code),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.labelSmall.copy(
|
MaterialTheme.typography.labelSmall.copy(
|
||||||
color = statusColors.error,
|
color = statusColors.warning,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// ── Generated/resolved image preview ──
|
||||||
|
view.imageUrl?.let { url ->
|
||||||
|
AsyncImage(
|
||||||
|
model = url,
|
||||||
|
contentDescription = view.title,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 200.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── File diff ──
|
// ── File diff ──
|
||||||
if (view.inlineDiff != null) {
|
if (view.inlineDiff != null) {
|
||||||
DiffViewCard(
|
DiffViewCard(
|
||||||
@@ -479,6 +522,11 @@ private fun ExpandedToolContent(
|
|||||||
if (view.detail.isNotBlank() && view.inlineDiff == null) {
|
if (view.detail.isNotBlank() && view.inlineDiff == null) {
|
||||||
Text(
|
Text(
|
||||||
text = view.detail,
|
text = view.detail,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 240.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.bodySmall.copy(
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
color = contentColor.copy(alpha = 0.9f),
|
color = contentColor.copy(alpha = 0.9f),
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ object ToolSchemaRegistry {
|
|||||||
summaryArgKey = null,
|
summaryArgKey = null,
|
||||||
icon = Icons.Filled.Checklist,
|
icon = Icons.Filled.Checklist,
|
||||||
),
|
),
|
||||||
|
"todo_list" to
|
||||||
|
ToolDisplayConfig(
|
||||||
|
name = "todo_list",
|
||||||
|
summaryArgKey = null,
|
||||||
|
icon = Icons.Filled.Checklist,
|
||||||
|
),
|
||||||
"fact_store" to
|
"fact_store" to
|
||||||
ToolDisplayConfig(
|
ToolDisplayConfig(
|
||||||
name = "fact_store",
|
name = "fact_store",
|
||||||
@@ -150,6 +156,12 @@ object ToolSchemaRegistry {
|
|||||||
summaryArgKey = "action",
|
summaryArgKey = "action",
|
||||||
icon = Icons.Filled.Schedule,
|
icon = Icons.Filled.Schedule,
|
||||||
),
|
),
|
||||||
|
"cronjob_manage" to
|
||||||
|
ToolDisplayConfig(
|
||||||
|
name = "cronjob_manage",
|
||||||
|
summaryArgKey = "action",
|
||||||
|
icon = Icons.Filled.Schedule,
|
||||||
|
),
|
||||||
"memory" to
|
"memory" to
|
||||||
ToolDisplayConfig(
|
ToolDisplayConfig(
|
||||||
name = "memory",
|
name = "memory",
|
||||||
@@ -168,6 +180,12 @@ object ToolSchemaRegistry {
|
|||||||
summaryArgKey = "action",
|
summaryArgKey = "action",
|
||||||
icon = Icons.Filled.Settings,
|
icon = Icons.Filled.Settings,
|
||||||
),
|
),
|
||||||
|
"process_manage" to
|
||||||
|
ToolDisplayConfig(
|
||||||
|
name = "process_manage",
|
||||||
|
summaryArgKey = "action",
|
||||||
|
icon = Icons.Filled.Settings,
|
||||||
|
),
|
||||||
"skill_manage" to
|
"skill_manage" to
|
||||||
ToolDisplayConfig(
|
ToolDisplayConfig(
|
||||||
name = "skill_manage",
|
name = "skill_manage",
|
||||||
@@ -187,6 +205,42 @@ object ToolSchemaRegistry {
|
|||||||
summaryArgKey = null,
|
summaryArgKey = null,
|
||||||
icon = Icons.Filled.Keyboard,
|
icon = Icons.Filled.Keyboard,
|
||||||
),
|
),
|
||||||
|
"browser_scroll" to
|
||||||
|
ToolDisplayConfig(
|
||||||
|
name = "browser_scroll",
|
||||||
|
summaryArgKey = "direction",
|
||||||
|
icon = Icons.Filled.TouchApp,
|
||||||
|
),
|
||||||
|
"browser_back" to
|
||||||
|
ToolDisplayConfig(
|
||||||
|
name = "browser_back",
|
||||||
|
summaryArgKey = null,
|
||||||
|
icon = Icons.Filled.Language,
|
||||||
|
),
|
||||||
|
"browser_press" to
|
||||||
|
ToolDisplayConfig(
|
||||||
|
name = "browser_press",
|
||||||
|
summaryArgKey = "key",
|
||||||
|
icon = Icons.Filled.Keyboard,
|
||||||
|
),
|
||||||
|
"browser_get_images" to
|
||||||
|
ToolDisplayConfig(
|
||||||
|
name = "browser_get_images",
|
||||||
|
summaryArgKey = null,
|
||||||
|
icon = Icons.Filled.Image,
|
||||||
|
),
|
||||||
|
"browser_vision" to
|
||||||
|
ToolDisplayConfig(
|
||||||
|
name = "browser_vision",
|
||||||
|
summaryArgKey = "question",
|
||||||
|
icon = Icons.Filled.Visibility,
|
||||||
|
),
|
||||||
|
"browser_console" to
|
||||||
|
ToolDisplayConfig(
|
||||||
|
name = "browser_console",
|
||||||
|
summaryArgKey = null,
|
||||||
|
icon = Icons.Filled.Terminal,
|
||||||
|
),
|
||||||
"browser_cdp" to
|
"browser_cdp" to
|
||||||
ToolDisplayConfig(
|
ToolDisplayConfig(
|
||||||
name = "browser_cdp",
|
name = "browser_cdp",
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ import kotlinx.serialization.json.JsonPrimitive
|
|||||||
internal object ToolJson {
|
internal object ToolJson {
|
||||||
private val URL_PATTERN = Regex("https?://[^\\s'\"<>)\\]]+", RegexOption.IGNORE_CASE)
|
private val URL_PATTERN = Regex("https?://[^\\s'\"<>)\\]]+", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
const val MAX_TOOL_RENDER_CHARS = 20_000
|
||||||
|
|
||||||
|
fun clampForDisplay(
|
||||||
|
value: String,
|
||||||
|
max: Int = MAX_TOOL_RENDER_CHARS,
|
||||||
|
): String {
|
||||||
|
if (value.length <= max) return value
|
||||||
|
val omitted = value.length - max
|
||||||
|
return value.take(max) +
|
||||||
|
"\n… $omitted more characters truncated — use Copy for the full output."
|
||||||
|
}
|
||||||
|
|
||||||
/** First non-blank string value among [keys], trimmed. */
|
/** First non-blank string value among [keys], trimmed. */
|
||||||
fun firstString(
|
fun firstString(
|
||||||
record: JsonObject?,
|
record: JsonObject?,
|
||||||
@@ -294,7 +306,11 @@ internal object ToolJson {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun durationLabel(result: JsonObject?): String? {
|
fun durationLabel(result: JsonObject?): String? {
|
||||||
val seconds = numberValue(result?.get("duration_s")) ?: return null
|
val seconds =
|
||||||
|
numberValue(result?.get("duration_s"))
|
||||||
|
?: numberValue(result?.get("duration_seconds"))
|
||||||
|
?: numberValue(result?.get("total_duration_seconds"))
|
||||||
|
?: return null
|
||||||
|
|
||||||
return formatDurationSeconds(seconds)
|
return formatDurationSeconds(seconds)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.tool
|
package com.m57.hermescontrol.ui.chat.tool
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.render.BrowserActionRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.render.BrowserConsoleRenderer
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.render.BrowserImagesRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserNavigateRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.BrowserNavigateRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.render.BrowserVisionRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.ComputerUseRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.ComputerUseRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.render.DelegateTaskRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.FactStoreRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.FactStoreRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.ImageGenerateRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.ImageGenerateRenderer
|
||||||
@@ -15,11 +20,13 @@ import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer
|
|||||||
import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.render.SearchFilesRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.SessionSearchRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.SessionSearchRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.SkillViewRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.SkillViewRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.render.TextToSpeechRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer
|
||||||
import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer
|
import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer
|
||||||
@@ -44,11 +51,14 @@ internal object ToolRendererRegistry {
|
|||||||
"terminal" to TerminalRenderer,
|
"terminal" to TerminalRenderer,
|
||||||
"execute_code" to TerminalRenderer,
|
"execute_code" to TerminalRenderer,
|
||||||
"read_terminal" to ReadTerminalRenderer,
|
"read_terminal" to ReadTerminalRenderer,
|
||||||
|
// Delegation
|
||||||
|
"delegate_task" to DelegateTaskRenderer,
|
||||||
// Files
|
// Files
|
||||||
"read_file" to ReadFileRenderer,
|
"read_file" to ReadFileRenderer,
|
||||||
"edit_file" to FileEditRenderer,
|
"edit_file" to FileEditRenderer,
|
||||||
"patch" to FileEditRenderer,
|
"patch" to FileEditRenderer,
|
||||||
"write_file" to FileEditRenderer,
|
"write_file" to FileEditRenderer,
|
||||||
|
"search_files" to SearchFilesRenderer,
|
||||||
// Web
|
// Web
|
||||||
"web_search" to WebSearchRenderer,
|
"web_search" to WebSearchRenderer,
|
||||||
"web_extract" to WebExtractRenderer,
|
"web_extract" to WebExtractRenderer,
|
||||||
@@ -59,14 +69,23 @@ internal object ToolRendererRegistry {
|
|||||||
"browser_click" to BrowserClickRenderer,
|
"browser_click" to BrowserClickRenderer,
|
||||||
"browser_fill" to BrowserTypeRenderer,
|
"browser_fill" to BrowserTypeRenderer,
|
||||||
"browser_type" to BrowserTypeRenderer,
|
"browser_type" to BrowserTypeRenderer,
|
||||||
|
"browser_scroll" to BrowserActionRenderer,
|
||||||
|
"browser_back" to BrowserActionRenderer,
|
||||||
|
"browser_press" to BrowserActionRenderer,
|
||||||
|
"browser_get_images" to BrowserImagesRenderer,
|
||||||
|
"browser_vision" to BrowserVisionRenderer,
|
||||||
|
"browser_console" to BrowserConsoleRenderer,
|
||||||
// Memory / facts
|
// Memory / facts
|
||||||
"memory" to MemoryRenderer,
|
"memory" to MemoryRenderer,
|
||||||
"fact_store" to FactStoreRenderer,
|
"fact_store" to FactStoreRenderer,
|
||||||
// Task management
|
// Task management
|
||||||
"cronjob" to CronjobRenderer,
|
"cronjob" to CronjobRenderer,
|
||||||
|
"cronjob_manage" to CronjobRenderer,
|
||||||
"todo" to TodoRenderer,
|
"todo" to TodoRenderer,
|
||||||
|
"todo_list" to TodoRenderer,
|
||||||
"session_search" to SessionSearchRenderer,
|
"session_search" to SessionSearchRenderer,
|
||||||
"process" to ProcessRenderer,
|
"process" to ProcessRenderer,
|
||||||
|
"process_manage" to ProcessRenderer,
|
||||||
// Skills / tool discovery
|
// Skills / tool discovery
|
||||||
"skills_list" to SkillsListRenderer,
|
"skills_list" to SkillsListRenderer,
|
||||||
"skill_view" to SkillViewRenderer,
|
"skill_view" to SkillViewRenderer,
|
||||||
@@ -75,6 +94,7 @@ internal object ToolRendererRegistry {
|
|||||||
// Media
|
// Media
|
||||||
"image_generate" to ImageGenerateRenderer,
|
"image_generate" to ImageGenerateRenderer,
|
||||||
"vision_analyze" to VisionAnalyzeRenderer,
|
"vision_analyze" to VisionAnalyzeRenderer,
|
||||||
|
"text_to_speech" to TextToSpeechRenderer,
|
||||||
// Projects
|
// Projects
|
||||||
"project_list" to ProjectListRenderer,
|
"project_list" to ProjectListRenderer,
|
||||||
"project_create" to ProjectMutateRenderer,
|
"project_create" to ProjectMutateRenderer,
|
||||||
|
|||||||
@@ -89,19 +89,24 @@ object ToolViewBuilder {
|
|||||||
ToolCounts.countLabelFor(call)
|
ToolCounts.countLabelFor(call)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val clampedDetail = ToolJson.clampForDisplay(detail)
|
||||||
|
val clampedStdout = extras.stdout?.let { ToolJson.clampForDisplay(it) }
|
||||||
|
val clampedStderr = extras.stderr?.let { ToolJson.clampForDisplay(it) }
|
||||||
|
val clampedDiff = extras.inlineDiff?.ifEmpty { null }?.let { ToolJson.clampForDisplay(it) }
|
||||||
|
|
||||||
return ToolView(
|
return ToolView(
|
||||||
status = status,
|
status = status,
|
||||||
title = title,
|
title = title,
|
||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
detail = detail,
|
detail = clampedDetail,
|
||||||
detailLabel = extras.detailLabel,
|
detailLabel = extras.detailLabel,
|
||||||
countLabel = countLabel?.let { (count, noun) -> "$count ${ToolCounts.pluralizeNoun(noun, count)}" },
|
countLabel = countLabel?.let { (count, noun) -> "$count ${ToolCounts.pluralizeNoun(noun, count)}" },
|
||||||
durationLabel = ToolJson.durationLabel(call.result),
|
durationLabel = ToolJson.durationLabel(call.result),
|
||||||
stdout = extras.stdout,
|
stdout = clampedStdout,
|
||||||
stderr = extras.stderr,
|
stderr = clampedStderr,
|
||||||
exitCode = extras.exitCode,
|
exitCode = extras.exitCode,
|
||||||
terminalCommand = extras.terminalCommand,
|
terminalCommand = extras.terminalCommand,
|
||||||
inlineDiff = extras.inlineDiff?.ifEmpty { null },
|
inlineDiff = clampedDiff,
|
||||||
diffPath = extras.diffPath,
|
diffPath = extras.diffPath,
|
||||||
diffStats = extras.diffStats,
|
diffStats = extras.diffStats,
|
||||||
imageUrl = imageUrlFor(call.args, call.result),
|
imageUrl = imageUrlFor(call.args, call.result),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat.tool.render
|
|||||||
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||||
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
|
||||||
/** `browser_navigate`: hostname titles, including the failed-to-open form. */
|
/** `browser_navigate`: hostname titles, including the failed-to-open form. */
|
||||||
internal object BrowserNavigateRenderer : ToolRenderer {
|
internal object BrowserNavigateRenderer : ToolRenderer {
|
||||||
@@ -104,3 +105,98 @@ internal object BrowserTypeRenderer : ToolRenderer {
|
|||||||
.ifEmpty { "Filled page input" }
|
.ifEmpty { "Filled page input" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** browser_scroll / browser_back / browser_press: one-line acks. */
|
||||||
|
internal object BrowserActionRenderer : ToolRenderer {
|
||||||
|
override fun doneTitle(call: ToolCall): String? =
|
||||||
|
when (call.name) {
|
||||||
|
"browser_scroll" -> {
|
||||||
|
"Scrolled ${ToolJson.firstString(call.result, listOf("scrolled"))
|
||||||
|
.ifEmpty { ToolJson.firstString(call.args, listOf("direction")) }.ifEmpty { "page" }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
"browser_back" -> {
|
||||||
|
val url = ToolJson.firstString(call.result, listOf("url"))
|
||||||
|
if (url.isNotEmpty()) "Went back to ${ToolJson.hostnameOf(url)}" else "Went back"
|
||||||
|
}
|
||||||
|
|
||||||
|
"browser_press" -> {
|
||||||
|
"Pressed ${ToolJson.firstString(call.result, listOf("pressed"))
|
||||||
|
.ifEmpty { ToolJson.firstString(call.args, listOf("key")) }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun subtitle(call: ToolCall): String = ""
|
||||||
|
|
||||||
|
override fun detail(call: ToolCall): String = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** browser_get_images: image inventory of the current page. */
|
||||||
|
internal object BrowserImagesRenderer : ToolRenderer {
|
||||||
|
override fun doneTitle(call: ToolCall): String {
|
||||||
|
val n = ToolJson.intValue(call.result?.get("count")) ?: 0
|
||||||
|
return "Found $n image${if (n == 1) "" else "s"}"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun detail(call: ToolCall): String =
|
||||||
|
(call.result?.get("images") as? JsonArray)
|
||||||
|
?.take(12)
|
||||||
|
?.mapNotNull { el ->
|
||||||
|
val img = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||||
|
val src = ToolJson.firstString(img, listOf("src"))
|
||||||
|
val alt = ToolJson.firstString(img, listOf("alt"))
|
||||||
|
listOf(alt, src)
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.joinToString(" — ")
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { "- $it" }
|
||||||
|
}?.joinToString("\n") ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** browser_vision: screenshot Q&A — the analysis text is the payload. */
|
||||||
|
internal object BrowserVisionRenderer : ToolRenderer {
|
||||||
|
override fun pendingTitle(call: ToolCall): String = "Looking at page"
|
||||||
|
|
||||||
|
override fun doneTitle(call: ToolCall): String = "Analyzed page"
|
||||||
|
|
||||||
|
override fun subtitle(call: ToolCall): String =
|
||||||
|
ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("question")), 120)
|
||||||
|
|
||||||
|
override fun detail(call: ToolCall): String =
|
||||||
|
ToolJson
|
||||||
|
.firstString(call.result, listOf("analysis"))
|
||||||
|
.ifEmpty { ToolJson.firstString(call.result, listOf("note")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** browser_console: message/error counts, or a JS eval result. */
|
||||||
|
internal object BrowserConsoleRenderer : ToolRenderer {
|
||||||
|
override fun doneTitle(call: ToolCall): String? {
|
||||||
|
val result = call.result ?: return null
|
||||||
|
if (result.containsKey("result")) return "Evaluated JS"
|
||||||
|
val msgs = ToolJson.intValue(result["total_messages"]) ?: 0
|
||||||
|
val errs = ToolJson.intValue(result["total_errors"]) ?: 0
|
||||||
|
return "Console: $msgs message${if (msgs == 1) "" else "s"}" +
|
||||||
|
if (errs > 0) ", $errs error${if (errs == 1) "" else "s"}" else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun detail(call: ToolCall): String {
|
||||||
|
val result = call.result ?: return ""
|
||||||
|
result["result"]?.let { return it.toString() }
|
||||||
|
val lines =
|
||||||
|
(result["console_messages"] as? JsonArray)?.take(30)?.mapNotNull { el ->
|
||||||
|
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||||
|
"[${ToolJson.firstString(m, listOf("type")).ifEmpty { "log" }}] " +
|
||||||
|
ToolJson.compactPreview(ToolJson.firstString(m, listOf("text")), 200)
|
||||||
|
} ?: emptyList()
|
||||||
|
val errors =
|
||||||
|
(result["js_errors"] as? JsonArray)?.take(10)?.mapNotNull { el ->
|
||||||
|
val e = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||||
|
"[error] ${ToolJson.compactPreview(ToolJson.firstString(e, listOf("message")), 200)}"
|
||||||
|
} ?: emptyList()
|
||||||
|
return (errors + lines).joinToString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.tool.render
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `delegate_task`: fan-out of subagent tasks.
|
||||||
|
* Covers joined results, background dispatch handles, and list/steer/stop acks.
|
||||||
|
*/
|
||||||
|
internal object DelegateTaskRenderer : ToolRenderer {
|
||||||
|
private fun goals(call: ToolCall): List<String> {
|
||||||
|
val tasks = call.args?.get("tasks") as? JsonArray
|
||||||
|
val fromTasks =
|
||||||
|
tasks?.mapIndexedNotNull { i, el ->
|
||||||
|
ToolJson
|
||||||
|
.parseMaybeObject(el)
|
||||||
|
?.let { ToolJson.firstString(it, listOf("goal")) }
|
||||||
|
?.ifEmpty { "Task ${i + 1}" }
|
||||||
|
} ?: emptyList()
|
||||||
|
if (fromTasks.isNotEmpty()) return fromTasks
|
||||||
|
return ToolJson
|
||||||
|
.firstString(call.args, listOf("goal"))
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { listOf(it) } ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun action(call: ToolCall) = ToolJson.firstString(call.args, listOf("action")).ifEmpty { "spawn" }
|
||||||
|
|
||||||
|
override fun pendingTitle(call: ToolCall): String =
|
||||||
|
when (action(call)) {
|
||||||
|
"list" -> {
|
||||||
|
"Listing subagents"
|
||||||
|
}
|
||||||
|
|
||||||
|
"steer" -> {
|
||||||
|
"Steering subagent"
|
||||||
|
}
|
||||||
|
|
||||||
|
"stop" -> {
|
||||||
|
"Stopping subagent"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val n = goals(call).size.coerceAtLeast(1)
|
||||||
|
"Delegating $n task${if (n == 1) "" else "s"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun doneTitle(call: ToolCall): String {
|
||||||
|
val result = call.result
|
||||||
|
return when {
|
||||||
|
action(call) == "list" -> {
|
||||||
|
val n = ToolJson.intValue(result?.get("count")) ?: 0
|
||||||
|
"$n subagent${if (n == 1) "" else "s"}"
|
||||||
|
}
|
||||||
|
|
||||||
|
action(call) == "steer" -> {
|
||||||
|
"Steer queued"
|
||||||
|
}
|
||||||
|
|
||||||
|
action(call) == "stop" -> {
|
||||||
|
"Stop requested"
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolJson.firstString(result, listOf("status")) == "dispatched" -> {
|
||||||
|
val n = ToolJson.intValue(result?.get("count")) ?: goals(call).size
|
||||||
|
"Dispatched $n background task${if (n == 1) "" else "s"}"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val results = result?.get("results") as? JsonArray
|
||||||
|
val n = results?.size ?: goals(call).size.coerceAtLeast(1)
|
||||||
|
val failed =
|
||||||
|
results?.count {
|
||||||
|
ToolJson.firstString(ToolJson.parseMaybeObject(it), listOf("status")) == "failed"
|
||||||
|
} ?: 0
|
||||||
|
if (failed > 0) {
|
||||||
|
"Delegated $n tasks · $failed failed"
|
||||||
|
} else {
|
||||||
|
"Delegated $n task${if (n == 1) "" else "s"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun subtitle(call: ToolCall): String = ToolJson.compactPreview(goals(call).firstOrNull() ?: "", 120)
|
||||||
|
|
||||||
|
override fun detail(call: ToolCall): String {
|
||||||
|
val result = call.result
|
||||||
|
// list action: one row per live subagent
|
||||||
|
(result?.get("subagents") as? JsonArray)?.let { subs ->
|
||||||
|
return subs
|
||||||
|
.mapNotNull { el ->
|
||||||
|
val s = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||||
|
val goal = ToolJson.compactPreview(ToolJson.firstString(s, listOf("goal")), 80)
|
||||||
|
val status = ToolJson.firstString(s, listOf("status"))
|
||||||
|
val secs = ToolJson.numberValue(s["running_seconds"])
|
||||||
|
val time = secs?.let { " · ${ToolJson.formatDurationSeconds(it)}" } ?: ""
|
||||||
|
"- [$status] $goal$time"
|
||||||
|
}.joinToString("\n")
|
||||||
|
}
|
||||||
|
// joined spawn: per-task result rows
|
||||||
|
val rows =
|
||||||
|
(result?.get("results") as? JsonArray)?.mapNotNull { el ->
|
||||||
|
val r = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||||
|
val idx = ToolJson.intValue(r["task_index"]) ?: 0
|
||||||
|
val ok = ToolJson.firstString(r, listOf("status")) != "failed"
|
||||||
|
val marker = if (ok) "[x]" else "[!]"
|
||||||
|
val goal = goals(call).getOrNull(idx) ?: "Task ${idx + 1}"
|
||||||
|
val model = ToolJson.firstString(r, listOf("model"))
|
||||||
|
val dur =
|
||||||
|
ToolJson
|
||||||
|
.numberValue(r["duration_seconds"])
|
||||||
|
?.let { ToolJson.formatDurationSeconds(it) } ?: ""
|
||||||
|
val meta = listOf(model, dur).filter { it.isNotEmpty() }.joinToString(" · ")
|
||||||
|
val summary =
|
||||||
|
ToolJson.compactPreview(
|
||||||
|
ToolJson.firstString(r, listOf("summary", "error")),
|
||||||
|
160,
|
||||||
|
)
|
||||||
|
buildString {
|
||||||
|
append("$marker ${ToolJson.compactPreview(goal, 80)}")
|
||||||
|
if (meta.isNotEmpty()) append(" ($meta)")
|
||||||
|
if (summary.isNotEmpty()) append("\n $summary")
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
if (rows.isNotEmpty()) return rows.joinToString("\n")
|
||||||
|
// dispatched handle / steer / stop acks
|
||||||
|
val note = ToolJson.firstString(result, listOf("note"))
|
||||||
|
val goalsArr =
|
||||||
|
(result?.get("goals") as? JsonArray)
|
||||||
|
?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
|
||||||
|
?.joinToString("\n") { "- ${ToolJson.compactPreview(it, 100)}" } ?: ""
|
||||||
|
return listOf(goalsArr, note).filter { it.isNotEmpty() }.joinToString("\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,3 +55,32 @@ internal object VisionAnalyzeRenderer : ToolRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `text_to_speech`: audio synthesis. Success payload carries file_path(s), provider, chunk_count. */
|
||||||
|
internal object TextToSpeechRenderer : ToolRenderer {
|
||||||
|
override fun pendingTitle(call: ToolCall): String = "Generating speech"
|
||||||
|
|
||||||
|
override fun doneTitle(call: ToolCall): String = "Generated speech"
|
||||||
|
|
||||||
|
override fun subtitle(call: ToolCall): String {
|
||||||
|
val provider = ToolJson.firstString(call.result, listOf("provider"))
|
||||||
|
val chunks = ToolJson.intValue(call.result?.get("chunk_count"))
|
||||||
|
val parts =
|
||||||
|
listOfNotNull(
|
||||||
|
provider.takeIf { it.isNotEmpty() },
|
||||||
|
chunks?.takeIf { it > 1 }?.let { "$it chunks" },
|
||||||
|
)
|
||||||
|
return parts
|
||||||
|
.joinToString(" · ")
|
||||||
|
.ifEmpty { ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("text")), 120) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun detail(call: ToolCall): String {
|
||||||
|
val text = ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("text")), 400)
|
||||||
|
val path = ToolJson.firstString(call.result, listOf("file_path", "output_path"))
|
||||||
|
return listOfNotNull(
|
||||||
|
text.takeIf { it.isNotEmpty() }?.let { "“$it”" },
|
||||||
|
path.takeIf { it.isNotEmpty() }?.let { "Saved to $it" },
|
||||||
|
).joinToString("\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.tool.render
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.ToolCall
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `search_files`: ripgrep-backed content/file search.
|
||||||
|
* Handles both result shapes: dense path-grouped `matches_text` string (≥5 hits)
|
||||||
|
* and `matches:[{path,line,content}]` (<5 hits), plus `files:[path]`.
|
||||||
|
*/
|
||||||
|
internal object SearchFilesRenderer : ToolRenderer {
|
||||||
|
private fun pattern(call: ToolCall) = ToolJson.firstString(call.args, listOf("pattern"))
|
||||||
|
|
||||||
|
override fun pendingTitle(call: ToolCall): String? =
|
||||||
|
pattern(call).takeIf { it.isNotEmpty() }?.let { "Searching “${ToolJson.compactPreview(it, 48)}”" }
|
||||||
|
|
||||||
|
override fun doneTitle(call: ToolCall): String? {
|
||||||
|
val p = pattern(call).takeIf { it.isNotEmpty() } ?: return null
|
||||||
|
val total = ToolJson.intValue(call.result?.get("total_count"))
|
||||||
|
val suffix = total?.let { " · $it match${if (it == 1) "" else "es"}" } ?: ""
|
||||||
|
return "Searched “${ToolJson.compactPreview(p, 48)}”$suffix"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun subtitle(call: ToolCall): String {
|
||||||
|
val path = ToolJson.firstString(call.args, listOf("path")).takeIf { it != "." && it.isNotEmpty() } ?: ""
|
||||||
|
val glob = ToolJson.firstString(call.args, listOf("file_glob"))
|
||||||
|
return listOf(path, glob).filter { it.isNotEmpty() }.joinToString(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun detail(call: ToolCall): String {
|
||||||
|
val result = call.result ?: return ""
|
||||||
|
// Dense ≥5-match block: already ripgrep-grouped, use verbatim.
|
||||||
|
val dense = ToolJson.firstString(result, listOf("matches_text"))
|
||||||
|
val body =
|
||||||
|
if (dense.isNotEmpty()) {
|
||||||
|
dense
|
||||||
|
} else {
|
||||||
|
(result["matches"] as? JsonArray)
|
||||||
|
?.mapNotNull { el ->
|
||||||
|
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
|
||||||
|
val path = ToolJson.firstString(m, listOf("path"))
|
||||||
|
val line = ToolJson.intValue(m["line"])
|
||||||
|
val content = ToolJson.firstString(m, listOf("content"))
|
||||||
|
"$path:${line ?: "?"}: ${content.trim()}"
|
||||||
|
}?.joinToString("\n")
|
||||||
|
?: (result["files"] as? JsonArray)
|
||||||
|
?.mapNotNull {
|
||||||
|
(it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content
|
||||||
|
}?.joinToString("\n") ?: ""
|
||||||
|
}
|
||||||
|
val hint =
|
||||||
|
if (ToolJson.boolTrue(result["truncated"])) {
|
||||||
|
ToolJson.firstString(result, listOf("_hint")).ifEmpty { "Results truncated" }
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
return listOf(body, hint).filter { it.isNotEmpty() }.joinToString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun extras(
|
||||||
|
call: ToolCall,
|
||||||
|
status: ToolViewStatus,
|
||||||
|
): ToolViewExtras = ToolViewExtras(detailLabel = "Matches")
|
||||||
|
}
|
||||||
@@ -265,4 +265,159 @@ class ToolViewBuilderTest {
|
|||||||
assertEquals(ToolViewStatus.ERROR, view.status)
|
assertEquals(ToolViewStatus.ERROR, view.status)
|
||||||
assertTrue(view.error?.contains("Permission denied") == true)
|
assertTrue(view.error?.contains("Permission denied") == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── name aliases ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `process_manage alias activates ProcessRenderer`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"process_manage",
|
||||||
|
"""{"action":"list"}""",
|
||||||
|
"""{"processes":[{"pid":123,"command":"sleep 100","status":"running"}]}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("list", view.subtitle)
|
||||||
|
assertTrue(view.detail.contains("sleep 100"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `todo_list alias activates TodoRenderer`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"todo_list",
|
||||||
|
"""{"action":"create"}""",
|
||||||
|
"""{"summary":{"total":1,"pending":1,"completed":0},"todos":""" +
|
||||||
|
"""[{"id":"1","content":"write tests","status":"pending"}]}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("1 item (1 pending)", view.subtitle)
|
||||||
|
assertTrue(view.detail.contains("[ ] 1. write tests"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cronjob_manage alias activates CronjobRenderer`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"cronjob_manage",
|
||||||
|
"""{"action":"list"}""",
|
||||||
|
"""{"jobs":[{"name":"backup","schedule":"0 0 * * *"}]}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("1 cron job", view.subtitle)
|
||||||
|
assertTrue(view.detail.contains("- backup · 0 0 * * *"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── new renderers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search_files dense format parses matches_text and count`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"search_files",
|
||||||
|
"""{"pattern":"fun connect","path":"app/src"}""",
|
||||||
|
"""{"total_count":2,"matches_text":"app/src/A.kt:10: fun connect()\napp/src/B.kt:20: fun connect()"}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Searched “fun connect” · 2 matches", view.title)
|
||||||
|
assertEquals("app/src", view.subtitle)
|
||||||
|
assertTrue(view.detail.contains("app/src/A.kt:10: fun connect()"))
|
||||||
|
assertEquals("Matches", view.detailLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search_files sparse array format formats hits`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"search_files",
|
||||||
|
"""{"pattern":"test","path":"."}""",
|
||||||
|
"""{"total_count":1,"matches":[{"path":"foo.kt","line":42,"content":"val test = 1"}]}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Searched “test” · 1 match", view.title)
|
||||||
|
assertTrue(view.detail.contains("foo.kt:42: val test = 1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delegate_task handles joined spawn with status markers`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"delegate_task",
|
||||||
|
"""{"action":"spawn","tasks":[{"goal":"Research architecture"},{"goal":"Write code"}]}""",
|
||||||
|
"""{"results":[{"task_index":0,"status":"completed","model":"claude-3-7","summary":"Researched OK",""" +
|
||||||
|
""""duration_seconds":12.5},{"task_index":1,"status":"failed","error":"Build broke",""" +
|
||||||
|
""""duration_seconds":4.0}]}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Delegated 2 tasks · 1 failed", view.title)
|
||||||
|
assertEquals("Research architecture", view.subtitle)
|
||||||
|
assertTrue(view.detail.contains("[x] Research architecture"))
|
||||||
|
assertTrue(view.detail.contains("claude-3-7"))
|
||||||
|
assertTrue(view.detail.contains("[!] Write code"))
|
||||||
|
assertTrue(view.detail.contains("Build broke"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delegate_task handles list action`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"delegate_task",
|
||||||
|
"""{"action":"list"}""",
|
||||||
|
"""{"count":1,"subagents":[{"goal":"Background indexing","status":"running",""" +
|
||||||
|
""""running_seconds":35.0}]}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("1 subagent", view.title)
|
||||||
|
assertTrue(view.detail.contains("- [running] Background indexing · 35s"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `text_to_speech formats provider and audio path`() {
|
||||||
|
val view =
|
||||||
|
build(
|
||||||
|
"text_to_speech",
|
||||||
|
"""{"text":"Hello world"}""",
|
||||||
|
"""{"provider":"edge","chunk_count":1,"file_path":"/tmp/audio.mp3"}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Generated speech", view.title)
|
||||||
|
assertEquals("edge", view.subtitle)
|
||||||
|
assertTrue(view.detail.contains("“Hello world”"))
|
||||||
|
assertTrue(view.detail.contains("Saved to /tmp/audio.mp3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `browser action renderers format scroll, back, vision and console`() {
|
||||||
|
val scroll = build("browser_scroll", """{"direction":"down"}""", """{"scrolled":"down"}""")
|
||||||
|
assertEquals("Scrolled down", scroll.title)
|
||||||
|
|
||||||
|
val back = build("browser_back", """{}""", """{"url":"https://example.com/docs"}""")
|
||||||
|
assertEquals("Went back to example.com/docs", back.title)
|
||||||
|
|
||||||
|
val vision =
|
||||||
|
build("browser_vision", """{"question":"Is there a login button?"}""", """{"analysis":"Yes, top right"}""")
|
||||||
|
assertEquals("Analyzed page", vision.title)
|
||||||
|
assertEquals("Is there a login button?", vision.subtitle)
|
||||||
|
assertEquals("Yes, top right", vision.detail)
|
||||||
|
|
||||||
|
val console =
|
||||||
|
build(
|
||||||
|
"browser_console",
|
||||||
|
"""{}""",
|
||||||
|
"""{"total_messages":5,"total_errors":1,"js_errors":[{"message":"Uncaught TypeError"}]}""",
|
||||||
|
)
|
||||||
|
assertEquals("Console: 5 messages, 1 error", console.title)
|
||||||
|
assertTrue(console.detail.contains("[error] Uncaught TypeError"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `output is clamped to maximum display chars`() {
|
||||||
|
val longOutput = "a".repeat(25_000)
|
||||||
|
val view = build("terminal", """{"command":"dump"}""", """{"output":"$longOutput"}""")
|
||||||
|
|
||||||
|
val stdout = view.stdout
|
||||||
|
assertNotNull(stdout)
|
||||||
|
assertTrue(stdout!!.length < 25_000)
|
||||||
|
assertTrue(stdout.contains("more characters truncated"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -555,7 +555,7 @@ class ToolViewCharacterizationTest {
|
|||||||
// ── delegation / messaging fall back to the generic engine ───────────
|
// ── delegation / messaging fall back to the generic engine ───────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `delegate_task uses generic title and unwrapped response`() {
|
fun `delegate_task uses DelegateTaskRenderer`() {
|
||||||
val view =
|
val view =
|
||||||
build(
|
build(
|
||||||
"delegate_task",
|
"delegate_task",
|
||||||
@@ -563,8 +563,8 @@ class ToolViewCharacterizationTest {
|
|||||||
"""{"response":"All done","duration_s":63}""",
|
"""{"response":"All done","duration_s":63}""",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals("Delegate Task", view.title)
|
assertEquals("Delegated 1 task", view.title)
|
||||||
assertEquals("All done", view.subtitle)
|
assertEquals("Fix tests", view.subtitle)
|
||||||
assertEquals("", view.detail)
|
assertEquals("", view.detail)
|
||||||
assertEquals("1m 3s", view.durationLabel)
|
assertEquals("1m 3s", view.durationLabel)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user