From ee28ee5ce2e416d610b38ae2ffbd3b2b991df942 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:06:32 +0300 Subject: [PATCH] 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/hermescontrol/data/ws/EventParser.kt | 15 ++ .../com/m57/hermescontrol/data/ws/WsEvent.kt | 35 +++ .../m57/hermescontrol/ui/chat/ChatBubble.kt | 16 ++ .../m57/hermescontrol/ui/chat/ChatMessage.kt | 21 ++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 69 +++++ .../hermescontrol/ui/chat/ChatViewModel.kt | 2 + .../ui/chat/ChatWsEventReducer.kt | 118 ++++++++- .../hermescontrol/data/ws/EventParserTest.kt | 82 ++++++ .../ui/chat/ChatWsEventReducerTest.kt | 236 ++++++++++++++++++ 9 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index dc92bd73..ac5f34bb 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -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 ?: "" diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 1abea4c1..dd43ad7e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -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?, + val sessionId: String? = null, + ) : WsEvent() + // ── Interactive ────────────────────────────────────────────────────── data class ClarifyRequest( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 6c6db7bc..cc4d8e02 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -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)) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 22ee2e5c..24e670ab 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -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 { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index d2639d3f..1b4ab42f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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 = 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, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 5c873600..8bfd4fc0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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 = emptyList(), ) { /** Convenience — derived from [connectionStatus]. */ val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index c97e99e6..f831e899 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -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() @@ -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, + ) + } } /** diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index 6298352d..ace36f00 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -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")) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt new file mode 100644 index 00000000..439c4230 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -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) + } +}