* 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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user