feat(#538): wire tool.progress/generating + subagent.* events into chat (#567)

* feat(#538): wire tool.progress/generating + subagent.* events into chat

* fix(#538): remove subagent indicator on subagent.complete so it doesn't linger

* style(#538): drop dead Elvis on non-nullable event.text in onMessageComplete

---------

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-12 22:06:32 +03:00
1 parent 9e10c813d4
commit ee28ee5ce2
9 files changed
+592 -2

No files matched your search

@@ -90,6 +90,21 @@ object EventParser {
WsEvent.ToolComplete(name, payload, sessionId)
}
"tool.progress" -> {
val name = payload?.get("name") as? String
val preview = payload?.get("preview") as? String
WsEvent.ToolProgress(name, preview, sessionId)
}
"tool.generating" -> {
val name = payload?.get("name") as? String
WsEvent.ToolGenerating(name, sessionId)
}
"subagent.spawn_requested", "subagent.start", "subagent.progress", "subagent.complete" -> {
WsEvent.SubagentEvent(eventType, payload, sessionId)
}
"tool.output_risk" -> {
val toolId = payload?.get("tool_id") as? String ?: ""
val name = payload?.get("name") as? String ?: ""
@@ -79,6 +79,41 @@ sealed class WsEvent {
val sessionId: String? = null,
) : WsEvent()
/**
* Live tool execution progress with optional preview content.
*
* Events: `tool.progress`
* Payload: `{ name?: string, preview?: string }`
*/
data class ToolProgress(
val name: String? = null,
val preview: String? = null,
val sessionId: String? = null,
) : WsEvent()
/**
* Tool generation active state.
*
* Events: `tool.generating`
* Payload: `{ name?: string }`
*/
data class ToolGenerating(
val name: String? = null,
val sessionId: String? = null,
) : WsEvent()
/**
* Subagent execution and delegation events.
*
* Events: `subagent.spawn_requested`, `subagent.start`, `subagent.progress`, `subagent.complete`
* Payload includes goal, task_index, task_count, subagent_id, child_session_id, text, status, summary, duration_seconds.
*/
data class SubagentEvent(
val type: String,
val payload: Map<String, Any?>?,
val sessionId: String? = null,
) : WsEvent()
// ── Interactive ──────────────────────────────────────────────────────
data class ClarifyRequest(
@@ -1894,6 +1894,22 @@ private fun ToolBubble(
// ── Header row: icon + tool name ──
HeaderRow(message, config, contentColor)
// ── Tool progress preview (tool.progress) ──
if (message.toolStatus == ToolStatus.RUNNING && !message.progressPreview.isNullOrEmpty()) {
Text(
text = message.progressPreview,
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),
)
}
// ── Security risk chip (tool.output_risk) ──
val riskData = message.toolOutputRiskData
if (riskData != null && (riskData.risk == "medium" || riskData.risk == "high" || riskData.redacted)) {
@@ -43,6 +43,27 @@ data class ChatMessage(
* Transient — not persisted to SQLite.
*/
val toolOutputRiskData: ToolOutputRiskData? = null,
/**
* Live preview/progress text from the backend's [tool.progress] WS event.
* Transient — not persisted to SQLite.
*/
val progressPreview: String? = null,
)
/**
* Representation of subagent execution details for transient UI indicators.
*
* Events: `subagent.spawn_requested`, `subagent.start`, `subagent.progress`, `subagent.complete`
*/
data class SubagentIndicator(
val type: String, // subagent.spawn_requested / start / progress / complete
val goal: String? = null,
val taskIndex: Int? = null,
val taskCount: Int? = null,
val text: String? = null, // preview line
val status: String? = null, // complete only
val summary: String? = null, // complete only
val subagentId: String? = null,
)
enum class MessageRole {
@@ -106,6 +106,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@@ -439,6 +440,7 @@ fun ChatScreen(
lastAnimatedMessageId = lastAnimatedMessageId,
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
viewModel = viewModel,
subagentIndicators = state.subagentIndicators,
)
// Loading overlay
@@ -1709,6 +1711,7 @@ private fun ChatMessageList(
lastAnimatedMessageId: String?,
onLastAnimatedMessageIdChange: (String?) -> Unit,
viewModel: ChatViewModel,
subagentIndicators: List<SubagentIndicator> = emptyList(),
) {
// Lay children out vertically so the search bar occupies real layout space
// ABOVE the message list. Without this container the call site is a Box,
@@ -1835,6 +1838,14 @@ private fun ChatMessageList(
ThinkingIndicator(thinkingText)
}
}
// Subagent indicators
items(
items = subagentIndicators,
key = { indicator -> "subagent-${indicator.subagentId ?: indicator.goal ?: indicator.type}" },
) { indicator ->
SubagentIndicatorRow(indicator = indicator)
}
}
}
}
@@ -2102,3 +2113,61 @@ private fun FloatingHeart(
.graphicsLayer(alpha = alpha.value),
)
}
@Composable
private fun SubagentIndicatorRow(
indicator: SubagentIndicator,
modifier: Modifier = Modifier,
) {
androidx.compose.material3.Surface(
modifier =
modifier
.padding(horizontal = 12.dp, vertical = 4.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = "🔀",
style = MaterialTheme.typography.bodySmall,
)
val taskProgressText =
if (indicator.taskIndex != null && indicator.taskCount != null) {
" (${indicator.taskIndex}/${indicator.taskCount})"
} else {
""
}
val goalText = indicator.goal ?: "Subagent task"
val textPreview =
if (!indicator.text.isNullOrEmpty()) {
": ${indicator.text}"
} else if (!indicator.summary.isNullOrEmpty()) {
": ${indicator.summary}"
} else {
""
}
Text(
text = "$goalText$taskProgressText$textPreview",
style =
MaterialTheme.typography.bodySmall.copy(
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -84,6 +84,8 @@ data class ChatUiState(
val reactionKind: String? = null,
/** Monotonic trigger ID so consecutive same-kind reactions re-animate. */
val reactionTriggerId: Long = 0L,
/** Subagent delegation indicators (issue #538) — transient UI state. */
val subagentIndicators: List<SubagentIndicator> = emptyList(),
) {
/** Convenience — derived from [connectionStatus]. */
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
@@ -34,6 +34,9 @@ object ChatWsEventReducer {
is WsEvent.ToolComplete -> event.sessionId
is WsEvent.ToolOutputRisk -> event.sessionId
is WsEvent.ClarifyRequest -> event.sessionId
is WsEvent.ToolProgress -> event.sessionId
is WsEvent.ToolGenerating -> event.sessionId
is WsEvent.SubagentEvent -> event.sessionId
else -> null
}
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
@@ -70,6 +73,12 @@ object ChatWsEventReducer {
is WsEvent.ToolOutputRisk -> onToolOutputRisk(state, streamingState, event)
is WsEvent.ToolProgress -> onToolProgress(state, streamingState, event)
is WsEvent.ToolGenerating -> onToolGenerating(state, streamingState, event)
is WsEvent.SubagentEvent -> onSubagentEvent(state, streamingState, event)
is WsEvent.ClarifyRequest -> onClarifyRequest(state, streamingState, event)
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
@@ -217,12 +226,12 @@ object ChatWsEventReducer {
val streaming = streamingState.streamingMessage
val msg =
streaming?.copy(
content = event.text ?: streaming.content,
content = event.text,
isStreaming = false,
reasoningText = streamingState.reasoningText,
) ?: ChatMessage(
role = MessageRole.ASSISTANT,
content = event.text ?: "",
content = event.text,
reasoningText = streamingState.reasoningText,
)
val effects = mutableListOf<ReducerEffect>()
@@ -493,6 +502,111 @@ object ChatWsEventReducer {
state: ChatUiState,
streamingState: StreamingState,
): ReducerResult = ReducerResult(state = state, streamingState = streamingState)
private fun onToolProgress(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.ToolProgress,
): ReducerResult {
val messages = state.messages.toMutableList()
val toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(progressPreview = event.preview ?: "")
return ReducerResult(
state = state.copy(messages = messages),
streamingState = streamingState,
)
}
private fun onToolGenerating(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.ToolGenerating,
): ReducerResult {
val messages = state.messages.toMutableList()
val toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(progressPreview = "")
return ReducerResult(
state = state.copy(messages = messages),
streamingState = streamingState,
)
}
private fun onSubagentEvent(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.SubagentEvent,
): ReducerResult {
val goal = event.payload?.get("goal") as? String
val taskIndex = (event.payload?.get("task_index") as? Number)?.toInt()
val taskCount = (event.payload?.get("task_count") as? Number)?.toInt()
val text = event.payload?.get("text") as? String
val status = event.payload?.get("status") as? String
val summary = event.payload?.get("summary") as? String
val subagentId =
event.payload?.get(
"subagent_id",
) as? String ?: event.payload?.get("child_session_id") as? String
val indicators = state.subagentIndicators.toMutableList()
val idx =
if (subagentId != null) {
indicators.indexOfLast { it.subagentId == subagentId }
} else if (goal != null) {
indicators.indexOfLast { it.goal == goal }
} else {
-1
}
// A completed delegation is no longer "in flight" — drop its indicator
// so it doesn't linger in the chat after the subagent finishes.
if (event.type == "subagent.complete") {
if (idx >= 0) {
indicators.removeAt(idx)
return ReducerResult(
state = state.copy(subagentIndicators = indicators),
streamingState = streamingState,
)
}
return ReducerResult(state = state, streamingState = streamingState)
}
val indicator =
SubagentIndicator(
type = event.type,
goal = goal ?: (if (idx >= 0) indicators[idx].goal else null),
taskIndex = taskIndex ?: (if (idx >= 0) indicators[idx].taskIndex else null),
taskCount = taskCount ?: (if (idx >= 0) indicators[idx].taskCount else null),
text = text ?: (if (idx >= 0) indicators[idx].text else null),
status = status ?: (if (idx >= 0) indicators[idx].status else null),
summary = summary ?: (if (idx >= 0) indicators[idx].summary else null),
subagentId = subagentId ?: (if (idx >= 0) indicators[idx].subagentId else null),
)
if (idx >= 0) {
indicators[idx] = indicator
} else {
indicators.add(indicator)
}
return ReducerResult(
state = state.copy(subagentIndicators = indicators),
streamingState = streamingState,
)
}
}
/**
@@ -522,4 +522,86 @@ class EventParserTest {
val event = EventParser.parse(response, """{"null":"type"}""")
assertTrue(event is WsEvent.Unknown)
}
@Test
fun testParseToolProgress_returnsToolProgressEvent() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "tool.progress",
"session_id" to "sess-123",
"payload" to mapOf("name" to "web_search", "preview" to "downloading content..."),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ToolProgress)
val toolProgress = event as WsEvent.ToolProgress
assertEquals("web_search", toolProgress.name)
assertEquals("downloading content...", toolProgress.preview)
assertEquals("sess-123", toolProgress.sessionId)
}
@Test
fun testParseToolGenerating_returnsToolGeneratingEvent() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "tool.generating",
"session_id" to "sess-123",
"payload" to mapOf("name" to "code_writer"),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ToolGenerating)
val toolGenerating = event as WsEvent.ToolGenerating
assertEquals("code_writer", toolGenerating.name)
assertEquals("sess-123", toolGenerating.sessionId)
}
@Test
fun testParseSubagentStart_returnsSubagentEvent() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "subagent.start",
"session_id" to "sess-123",
"payload" to
mapOf(
"goal" to "research oauth providers",
"task_index" to 1,
"task_count" to 5,
"subagent_id" to "sub-456",
"text" to "analyzing docs...",
),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.SubagentEvent)
val subagentEvent = event as WsEvent.SubagentEvent
assertEquals("subagent.start", subagentEvent.type)
assertEquals("sess-123", subagentEvent.sessionId)
assertEquals("research oauth providers", subagentEvent.payload?.get("goal"))
assertEquals(1, (subagentEvent.payload?.get("task_index") as? Number)?.toInt())
assertEquals(5, (subagentEvent.payload?.get("task_count") as? Number)?.toInt())
assertEquals("sub-456", subagentEvent.payload?.get("subagent_id"))
assertEquals("analyzing docs...", subagentEvent.payload?.get("text"))
}
}
@@ -0,0 +1,236 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.WsEvent
import org.junit.Assert.assertEquals
import org.junit.Test
class ChatWsEventReducerTest {
@Test
fun testToolProgress_updatesProgressPreviewForMatchingRunningTool() {
val initialMessage =
ChatMessage(
role = MessageRole.TOOL,
content = "{}",
toolName = "web_search",
toolStatus = ToolStatus.RUNNING,
)
val state =
ChatUiState(
messages = listOf(initialMessage),
currentSessionId = "session-1",
)
val event =
WsEvent.ToolProgress(
name = "web_search",
preview = "fetching google...",
sessionId = "session-1",
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertEquals(1, result.state.messages.size)
val updatedMessage = result.state.messages.first()
assertEquals(ToolStatus.RUNNING, updatedMessage.toolStatus)
assertEquals("fetching google...", updatedMessage.progressPreview)
}
@Test
fun testToolGenerating_clearsProgressPreviewForMatchingRunningTool() {
val initialMessage =
ChatMessage(
role = MessageRole.TOOL,
content = "{}",
toolName = "code_writer",
toolStatus = ToolStatus.RUNNING,
progressPreview = "writing...",
)
val state =
ChatUiState(
messages = listOf(initialMessage),
currentSessionId = "session-1",
)
val event =
WsEvent.ToolGenerating(
name = "code_writer",
sessionId = "session-1",
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertEquals(1, result.state.messages.size)
val updatedMessage = result.state.messages.first()
assertEquals(ToolStatus.RUNNING, updatedMessage.toolStatus)
assertEquals("", updatedMessage.progressPreview)
}
@Test
fun testSubagentEvent_appendsToSubagentIndicators() {
val state =
ChatUiState(
currentSessionId = "session-1",
subagentIndicators = emptyList(),
)
val event =
WsEvent.SubagentEvent(
type = "subagent.start",
sessionId = "session-1",
payload =
mapOf(
"goal" to "analyze repository",
"task_index" to 2,
"task_count" to 4,
"subagent_id" to "sub-1",
"text" to "analyzing files",
),
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertEquals(1, result.state.subagentIndicators.size)
val indicator = result.state.subagentIndicators.first()
assertEquals("subagent.start", indicator.type)
assertEquals("analyze repository", indicator.goal)
assertEquals(2, indicator.taskIndex)
assertEquals(4, indicator.taskCount)
assertEquals("sub-1", indicator.subagentId)
assertEquals("analyzing files", indicator.text)
}
@Test
fun testSubagentEvent_updatesExistingIndicatorBySubagentId() {
val initialIndicator =
SubagentIndicator(
type = "subagent.start",
goal = "analyze repository",
taskIndex = 1,
taskCount = 4,
subagentId = "sub-1",
text = "starting",
)
val state =
ChatUiState(
currentSessionId = "session-1",
subagentIndicators = listOf(initialIndicator),
)
val event =
WsEvent.SubagentEvent(
type = "subagent.progress",
sessionId = "session-1",
payload =
mapOf(
"task_index" to 2,
"subagent_id" to "sub-1",
"text" to "in progress",
),
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertEquals(1, result.state.subagentIndicators.size)
val indicator = result.state.subagentIndicators.first()
assertEquals("subagent.progress", indicator.type)
assertEquals("analyze repository", indicator.goal)
assertEquals(2, indicator.taskIndex)
assertEquals(4, indicator.taskCount)
assertEquals("sub-1", indicator.subagentId)
assertEquals("in progress", indicator.text)
}
@Test
fun testSubagentComplete_removesIndicator() {
val initialIndicator =
SubagentIndicator(
type = "subagent.start",
goal = "analyze repository",
taskIndex = 1,
taskCount = 4,
subagentId = "sub-1",
text = "starting",
)
val state =
ChatUiState(
currentSessionId = "session-1",
subagentIndicators = listOf(initialIndicator),
)
val event =
WsEvent.SubagentEvent(
type = "subagent.complete",
sessionId = "session-1",
payload =
mapOf(
"subagent_id" to "sub-1",
"status" to "completed",
"summary" to "done",
),
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertEquals(0, result.state.subagentIndicators.size)
}
@Test
fun testSessionMismatch_isIgnored() {
val initialMessage =
ChatMessage(
role = MessageRole.TOOL,
content = "{}",
toolName = "web_search",
toolStatus = ToolStatus.RUNNING,
)
val state =
ChatUiState(
messages = listOf(initialMessage),
currentSessionId = "session-1",
)
val event =
WsEvent.ToolProgress(
name = "web_search",
preview = "fetching google...",
sessionId = "session-different",
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertEquals(1, result.state.messages.size)
val updatedMessage = result.state.messages.first()
assertEquals(ToolStatus.RUNNING, updatedMessage.toolStatus)
assertEquals(null, updatedMessage.progressPreview)
}
}