* 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)
|
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" -> {
|
"tool.output_risk" -> {
|
||||||
val toolId = payload?.get("tool_id") as? String ?: ""
|
val toolId = payload?.get("tool_id") as? String ?: ""
|
||||||
val name = payload?.get("name") as? String ?: ""
|
val name = payload?.get("name") as? String ?: ""
|
||||||
|
|||||||
@@ -79,6 +79,41 @@ sealed class WsEvent {
|
|||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
) : WsEvent()
|
) : 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 ──────────────────────────────────────────────────────
|
// ── Interactive ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class ClarifyRequest(
|
data class ClarifyRequest(
|
||||||
|
|||||||
@@ -1894,6 +1894,22 @@ private fun ToolBubble(
|
|||||||
// ── Header row: icon + tool name ──
|
// ── Header row: icon + tool name ──
|
||||||
HeaderRow(message, config, contentColor)
|
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) ──
|
// ── Security risk chip (tool.output_risk) ──
|
||||||
val riskData = message.toolOutputRiskData
|
val riskData = message.toolOutputRiskData
|
||||||
if (riskData != null && (riskData.risk == "medium" || riskData.risk == "high" || riskData.redacted)) {
|
if (riskData != null && (riskData.risk == "medium" || riskData.risk == "high" || riskData.redacted)) {
|
||||||
|
|||||||
@@ -43,6 +43,27 @@ data class ChatMessage(
|
|||||||
* Transient — not persisted to SQLite.
|
* Transient — not persisted to SQLite.
|
||||||
*/
|
*/
|
||||||
val toolOutputRiskData: ToolOutputRiskData? = null,
|
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 {
|
enum class MessageRole {
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
@@ -439,6 +440,7 @@ fun ChatScreen(
|
|||||||
lastAnimatedMessageId = lastAnimatedMessageId,
|
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||||
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
|
subagentIndicators = state.subagentIndicators,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Loading overlay
|
// Loading overlay
|
||||||
@@ -1709,6 +1711,7 @@ private fun ChatMessageList(
|
|||||||
lastAnimatedMessageId: String?,
|
lastAnimatedMessageId: String?,
|
||||||
onLastAnimatedMessageIdChange: (String?) -> Unit,
|
onLastAnimatedMessageIdChange: (String?) -> Unit,
|
||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
|
subagentIndicators: List<SubagentIndicator> = emptyList(),
|
||||||
) {
|
) {
|
||||||
// Lay children out vertically so the search bar occupies real layout space
|
// 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,
|
// ABOVE the message list. Without this container the call site is a Box,
|
||||||
@@ -1835,6 +1838,14 @@ private fun ChatMessageList(
|
|||||||
ThinkingIndicator(thinkingText)
|
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),
|
.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,
|
val reactionKind: String? = null,
|
||||||
/** Monotonic trigger ID so consecutive same-kind reactions re-animate. */
|
/** Monotonic trigger ID so consecutive same-kind reactions re-animate. */
|
||||||
val reactionTriggerId: Long = 0L,
|
val reactionTriggerId: Long = 0L,
|
||||||
|
/** Subagent delegation indicators (issue #538) — transient UI state. */
|
||||||
|
val subagentIndicators: List<SubagentIndicator> = emptyList(),
|
||||||
) {
|
) {
|
||||||
/** Convenience — derived from [connectionStatus]. */
|
/** Convenience — derived from [connectionStatus]. */
|
||||||
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
|
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.ToolComplete -> event.sessionId
|
is WsEvent.ToolComplete -> event.sessionId
|
||||||
is WsEvent.ToolOutputRisk -> event.sessionId
|
is WsEvent.ToolOutputRisk -> event.sessionId
|
||||||
is WsEvent.ClarifyRequest -> event.sessionId
|
is WsEvent.ClarifyRequest -> event.sessionId
|
||||||
|
is WsEvent.ToolProgress -> event.sessionId
|
||||||
|
is WsEvent.ToolGenerating -> event.sessionId
|
||||||
|
is WsEvent.SubagentEvent -> event.sessionId
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
|
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
|
||||||
@@ -70,6 +73,12 @@ object ChatWsEventReducer {
|
|||||||
|
|
||||||
is WsEvent.ToolOutputRisk -> onToolOutputRisk(state, streamingState, event)
|
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.ClarifyRequest -> onClarifyRequest(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
|
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
|
||||||
@@ -217,12 +226,12 @@ object ChatWsEventReducer {
|
|||||||
val streaming = streamingState.streamingMessage
|
val streaming = streamingState.streamingMessage
|
||||||
val msg =
|
val msg =
|
||||||
streaming?.copy(
|
streaming?.copy(
|
||||||
content = event.text ?: streaming.content,
|
content = event.text,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
reasoningText = streamingState.reasoningText,
|
reasoningText = streamingState.reasoningText,
|
||||||
) ?: ChatMessage(
|
) ?: ChatMessage(
|
||||||
role = MessageRole.ASSISTANT,
|
role = MessageRole.ASSISTANT,
|
||||||
content = event.text ?: "",
|
content = event.text,
|
||||||
reasoningText = streamingState.reasoningText,
|
reasoningText = streamingState.reasoningText,
|
||||||
)
|
)
|
||||||
val effects = mutableListOf<ReducerEffect>()
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
@@ -493,6 +502,111 @@ object ChatWsEventReducer {
|
|||||||
state: ChatUiState,
|
state: ChatUiState,
|
||||||
streamingState: StreamingState,
|
streamingState: StreamingState,
|
||||||
): ReducerResult = ReducerResult(state = state, 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"}""")
|
val event = EventParser.parse(response, """{"null":"type"}""")
|
||||||
assertTrue(event is WsEvent.Unknown)
|
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