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

@@ -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)
}
}