* feat(chat): live task delegation widget with inline card & sticky progress indicator (#736) * fix(chat): refine subagent isRunning status and fallback Compose list keys (#743) * feat(ci): add CodeQL analysis workflow & integrate todos into task widget (#736) - Add CodeQL workflow with manual build mode for java-kotlin - Render agent plan & todo items inside live delegation widget * ci(codeql): add advanced CodeQL workflow with continue-on-error for Kotlin 2.4.10 (#736) * ci(codeql): use build-mode none to prevent Kotlin 2.4.10 extractor error (#736) * revert(ci): remove codeql workflow (#736) * refactor(chat): remove inline task card and rely on sticky top bar (#736) - Keep StickySubagentBar and SubagentInspectionSheet for top widget UI --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
@@ -52,6 +52,30 @@ data class ChatMessage(
|
|||||||
val progressPreview: String? = null,
|
val progressPreview: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single live transcript log entry for subagent execution.
|
||||||
|
*/
|
||||||
|
data class SubagentLogLine(
|
||||||
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
val text: String,
|
||||||
|
val isError: Boolean = false,
|
||||||
|
val isSummary: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Representation of a single task/todo item in the agent's plan.
|
||||||
|
*/
|
||||||
|
data class TodoItem(
|
||||||
|
val id: String,
|
||||||
|
val content: String,
|
||||||
|
val status: String = "pending", // "pending" | "in_progress" | "completed" | "cancelled"
|
||||||
|
) {
|
||||||
|
val isCompleted: Boolean get() = status == "completed" || status == "done"
|
||||||
|
val isInProgress: Boolean get() = status == "in_progress" || status == "running"
|
||||||
|
val isCancelled: Boolean get() = status == "cancelled" || status == "failed"
|
||||||
|
val isPending: Boolean get() = status == "pending" || status == "queued"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Representation of subagent execution details for transient UI indicators.
|
* Representation of subagent execution details for transient UI indicators.
|
||||||
*
|
*
|
||||||
@@ -63,10 +87,18 @@ data class SubagentIndicator(
|
|||||||
val taskIndex: Int? = null,
|
val taskIndex: Int? = null,
|
||||||
val taskCount: Int? = null,
|
val taskCount: Int? = null,
|
||||||
val text: String? = null, // preview line
|
val text: String? = null, // preview line
|
||||||
val status: String? = null, // complete only
|
val status: String? = null, // running / queued / completed / failed
|
||||||
val summary: String? = null, // complete only
|
val summary: String? = null, // complete only
|
||||||
val subagentId: String? = null,
|
val subagentId: String? = null,
|
||||||
)
|
val logs: List<SubagentLogLine> = emptyList(),
|
||||||
|
val durationSeconds: Double? = null,
|
||||||
|
val model: String? = null,
|
||||||
|
) {
|
||||||
|
val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done"
|
||||||
|
val isFailed: Boolean get() = status == "failed" || status == "interrupted"
|
||||||
|
val isQueued: Boolean get() = status == "queued"
|
||||||
|
val isRunning: Boolean get() = !isComplete && !isFailed && !isQueued
|
||||||
|
}
|
||||||
|
|
||||||
enum class MessageRole {
|
enum class MessageRole {
|
||||||
USER,
|
USER,
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
|||||||
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
||||||
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.StickySubagentBar
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||||
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
|
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
|
||||||
@@ -186,6 +188,7 @@ fun ChatScreen(
|
|||||||
streamingState.streamingMessage,
|
streamingState.streamingMessage,
|
||||||
streamingState.isThinking,
|
streamingState.isThinking,
|
||||||
state.subagentIndicators,
|
state.subagentIndicators,
|
||||||
|
state.todos,
|
||||||
state.clarifyRequest,
|
state.clarifyRequest,
|
||||||
) {
|
) {
|
||||||
scrollController.onTailChanged(
|
scrollController.onTailChanged(
|
||||||
@@ -211,6 +214,7 @@ fun ChatScreen(
|
|||||||
var isListening by rememberSaveable { mutableStateOf(false) }
|
var isListening by rememberSaveable { mutableStateOf(false) }
|
||||||
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) }
|
||||||
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
@@ -469,6 +473,15 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StickySubagentBar(
|
||||||
|
indicators = state.subagentIndicators,
|
||||||
|
todos = state.todos,
|
||||||
|
onClick = {
|
||||||
|
showSubagentInspectionSheet = true
|
||||||
|
scrollController.resumeFollowing()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -493,7 +506,6 @@ fun ChatScreen(
|
|||||||
lastAnimatedMessageId = lastAnimatedMessageId,
|
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||||
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
subagentIndicators = state.subagentIndicators,
|
|
||||||
clarifyRequest = state.clarifyRequest,
|
clarifyRequest = state.clarifyRequest,
|
||||||
onRespondClarify = viewModel::respondToClarify,
|
onRespondClarify = viewModel::respondToClarify,
|
||||||
onDismissClarify = viewModel::dismissClarify,
|
onDismissClarify = viewModel::dismissClarify,
|
||||||
@@ -641,6 +653,14 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSubagentInspectionSheet) {
|
||||||
|
SubagentInspectionSheet(
|
||||||
|
indicators = state.subagentIndicators,
|
||||||
|
todos = state.todos,
|
||||||
|
onDismiss = { showSubagentInspectionSheet = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
viewingImage?.let { image ->
|
viewingImage?.let { image ->
|
||||||
ImageViewerDialog(
|
ImageViewerDialog(
|
||||||
image = image,
|
image = image,
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ data class ChatUiState(
|
|||||||
val reactionTriggerId: Long = 0L,
|
val reactionTriggerId: Long = 0L,
|
||||||
/** Subagent delegation indicators (issue #538) — transient UI state. */
|
/** Subagent delegation indicators (issue #538) — transient UI state. */
|
||||||
val subagentIndicators: List<SubagentIndicator> = emptyList(),
|
val subagentIndicators: List<SubagentIndicator> = emptyList(),
|
||||||
|
/** Agent todo / plan items (issue #736). */
|
||||||
|
val todos: List<TodoItem> = emptyList(),
|
||||||
) {
|
) {
|
||||||
/** Convenience — derived from [connectionStatus]. */
|
/** Convenience — derived from [connectionStatus]. */
|
||||||
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
|
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import com.m57.hermescontrol.data.remote.OkHttpProvider
|
|||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import com.m57.hermescontrol.data.ws.toJsonElement
|
import com.m57.hermescontrol.data.ws.toJsonElement
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure state reducer for WebSocket events.
|
* Pure state reducer for WebSocket events.
|
||||||
@@ -42,7 +45,18 @@ object ChatWsEventReducer {
|
|||||||
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
|
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
|
||||||
return ReducerResult(state = state, streamingState = streamingState)
|
return ReducerResult(state = state, streamingState = streamingState)
|
||||||
}
|
}
|
||||||
return reduceInternal(state, streamingState, event)
|
val result = reduceInternal(state, streamingState, event)
|
||||||
|
val hydratedTodos =
|
||||||
|
if (result.state.todos.isEmpty() && result.state.messages.isNotEmpty()) {
|
||||||
|
hydrateTodosFromMessages(result.state.messages)
|
||||||
|
} else {
|
||||||
|
result.state.todos
|
||||||
|
}
|
||||||
|
return if (hydratedTodos !== result.state.todos) {
|
||||||
|
result.copy(state = result.state.copy(todos = hydratedTodos))
|
||||||
|
} else {
|
||||||
|
result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reduceInternal(
|
private fun reduceInternal(
|
||||||
@@ -134,6 +148,13 @@ object ChatWsEventReducer {
|
|||||||
)
|
)
|
||||||
val effects = mutableListOf<ReducerEffect>()
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
|
|
||||||
|
val cleanedSubagents =
|
||||||
|
if (state.subagentIndicators.all { it.isComplete || it.isFailed }) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
state.subagentIndicators
|
||||||
|
}
|
||||||
|
|
||||||
// Build new state: finalize any orphan streaming message, then set the new one
|
// Build new state: finalize any orphan streaming message, then set the new one
|
||||||
var orphan: ChatMessage? = null
|
var orphan: ChatMessage? = null
|
||||||
val preState =
|
val preState =
|
||||||
@@ -143,10 +164,12 @@ object ChatWsEventReducer {
|
|||||||
state.copy(
|
state.copy(
|
||||||
messages = state.messages + finalized,
|
messages = state.messages + finalized,
|
||||||
isAgentTyping = true,
|
isAgentTyping = true,
|
||||||
|
subagentIndicators = cleanedSubagents,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
state.copy(
|
state.copy(
|
||||||
isAgentTyping = true,
|
isAgentTyping = true,
|
||||||
|
subagentIndicators = cleanedSubagents,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val newStreamingState =
|
val newStreamingState =
|
||||||
@@ -361,8 +384,11 @@ object ChatWsEventReducer {
|
|||||||
effects.add(ReducerEffect.PersistMessage(toolMessage, sid))
|
effects.add(ReducerEffect.PersistMessage(toolMessage, sid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val parsedTodos = if (event.name == "todo") extractTodosFromMap(event.data) else null
|
||||||
|
val nextTodos = parsedTodos ?: newState.todos
|
||||||
|
|
||||||
return ReducerResult(
|
return ReducerResult(
|
||||||
state = newState.copy(messages = newState.messages + toolMessage),
|
state = newState.copy(messages = newState.messages + toolMessage, todos = nextTodos),
|
||||||
effects = effects,
|
effects = effects,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -399,8 +425,12 @@ object ChatWsEventReducer {
|
|||||||
if (sid != null) {
|
if (sid != null) {
|
||||||
effects.add(ReducerEffect.PersistMessage(updated, sid))
|
effects.add(ReducerEffect.PersistMessage(updated, sid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val parsedTodos = if (event.name == "todo") extractTodosFromMap(event.data) else null
|
||||||
|
val nextTodos = parsedTodos ?: state.todos
|
||||||
|
|
||||||
return ReducerResult(
|
return ReducerResult(
|
||||||
state = state.copy(messages = messages),
|
state = state.copy(messages = messages, todos = nextTodos),
|
||||||
streamingState = streamingState,
|
streamingState = streamingState,
|
||||||
effects = effects,
|
effects = effects,
|
||||||
)
|
)
|
||||||
@@ -600,6 +630,8 @@ object ChatWsEventReducer {
|
|||||||
val text = event.payload?.get("text") as? String
|
val text = event.payload?.get("text") as? String
|
||||||
val status = event.payload?.get("status") as? String
|
val status = event.payload?.get("status") as? String
|
||||||
val summary = event.payload?.get("summary") as? String
|
val summary = event.payload?.get("summary") as? String
|
||||||
|
val durationSeconds = (event.payload?.get("duration_seconds") as? Number)?.toDouble()
|
||||||
|
val model = event.payload?.get("model") as? String
|
||||||
val subagentId =
|
val subagentId =
|
||||||
event.payload?.get(
|
event.payload?.get(
|
||||||
"subagent_id",
|
"subagent_id",
|
||||||
@@ -615,18 +647,22 @@ object ChatWsEventReducer {
|
|||||||
-1
|
-1
|
||||||
}
|
}
|
||||||
|
|
||||||
// A completed delegation is no longer "in flight" — drop its indicator
|
val existingLogs = if (idx >= 0) indicators[idx].logs else emptyList()
|
||||||
// so it doesn't linger in the chat after the subagent finishes.
|
val newLogs = existingLogs.toMutableList()
|
||||||
if (event.type == "subagent.complete") {
|
if (!text.isNullOrBlank() && newLogs.lastOrNull()?.text != text) {
|
||||||
if (idx >= 0) {
|
newLogs.add(SubagentLogLine(text = text, isError = status == "failed"))
|
||||||
indicators.removeAt(idx)
|
|
||||||
return ReducerResult(
|
|
||||||
state = state.copy(subagentIndicators = indicators),
|
|
||||||
streamingState = streamingState,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return ReducerResult(state = state, streamingState = streamingState)
|
|
||||||
}
|
}
|
||||||
|
if (event.type == "subagent.complete" && !summary.isNullOrBlank() && newLogs.lastOrNull()?.text != summary) {
|
||||||
|
newLogs.add(SubagentLogLine(text = summary, isSummary = true))
|
||||||
|
}
|
||||||
|
val trimmedLogs = newLogs.takeLast(30)
|
||||||
|
|
||||||
|
val finalStatus =
|
||||||
|
if (event.type == "subagent.complete") {
|
||||||
|
status ?: "completed"
|
||||||
|
} else {
|
||||||
|
status ?: (if (idx >= 0) indicators[idx].status else "running")
|
||||||
|
}
|
||||||
|
|
||||||
val indicator =
|
val indicator =
|
||||||
SubagentIndicator(
|
SubagentIndicator(
|
||||||
@@ -634,10 +670,13 @@ object ChatWsEventReducer {
|
|||||||
goal = goal ?: (if (idx >= 0) indicators[idx].goal else null),
|
goal = goal ?: (if (idx >= 0) indicators[idx].goal else null),
|
||||||
taskIndex = taskIndex ?: (if (idx >= 0) indicators[idx].taskIndex else null),
|
taskIndex = taskIndex ?: (if (idx >= 0) indicators[idx].taskIndex else null),
|
||||||
taskCount = taskCount ?: (if (idx >= 0) indicators[idx].taskCount else null),
|
taskCount = taskCount ?: (if (idx >= 0) indicators[idx].taskCount else null),
|
||||||
text = text ?: (if (idx >= 0) indicators[idx].text else null),
|
text = summary ?: text ?: (if (idx >= 0) indicators[idx].text else null),
|
||||||
status = status ?: (if (idx >= 0) indicators[idx].status else null),
|
status = finalStatus,
|
||||||
summary = summary ?: (if (idx >= 0) indicators[idx].summary else null),
|
summary = summary ?: (if (idx >= 0) indicators[idx].summary else null),
|
||||||
subagentId = subagentId ?: (if (idx >= 0) indicators[idx].subagentId else null),
|
subagentId = subagentId ?: (if (idx >= 0) indicators[idx].subagentId else null),
|
||||||
|
logs = trimmedLogs,
|
||||||
|
durationSeconds = durationSeconds ?: (if (idx >= 0) indicators[idx].durationSeconds else null),
|
||||||
|
model = model ?: (if (idx >= 0) indicators[idx].model else null),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
@@ -701,3 +740,62 @@ sealed class ReducerEffect {
|
|||||||
val messageId: String,
|
val messageId: String,
|
||||||
) : ReducerEffect()
|
) : ReducerEffect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
fun extractTodosFromMap(data: Map<String, Any?>?): List<TodoItem>? {
|
||||||
|
if (data == null) return null
|
||||||
|
val rawList =
|
||||||
|
(data["todos"] as? List<*>)
|
||||||
|
?: ((data["args"] as? Map<String, Any?>)?.get("todos") as? List<*>)
|
||||||
|
?: ((data["parameters"] as? Map<String, Any?>)?.get("todos") as? List<*>)
|
||||||
|
?: ((data["result"] as? Map<String, Any?>)?.get("todos") as? List<*>)
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
val items = mutableListOf<TodoItem>()
|
||||||
|
for (elem in rawList) {
|
||||||
|
val map = elem as? Map<String, Any?> ?: continue
|
||||||
|
val id = (map["id"] as? String) ?: continue
|
||||||
|
val content = (map["content"] as? String) ?: (map["text"] as? String) ?: ""
|
||||||
|
val status = (map["status"] as? String) ?: "pending"
|
||||||
|
items.add(TodoItem(id = id, content = content, status = status))
|
||||||
|
}
|
||||||
|
return items.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun extractTodosFromJson(content: String): List<TodoItem>? {
|
||||||
|
if (content.isBlank()) return null
|
||||||
|
return try {
|
||||||
|
val element = OkHttpProvider.json.parseToJsonElement(content)
|
||||||
|
val obj = element as? JsonObject ?: return null
|
||||||
|
val todosArray =
|
||||||
|
(obj["todos"] as? JsonArray)
|
||||||
|
?: ((obj["args"] as? JsonObject)?.get("todos") as? JsonArray)
|
||||||
|
?: ((obj["result"] as? JsonObject)?.get("todos") as? JsonArray)
|
||||||
|
?: ((obj["parameters"] as? JsonObject)?.get("todos") as? JsonArray)
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
todosArray.mapNotNull { item ->
|
||||||
|
val itemObj = item as? JsonObject ?: return@mapNotNull null
|
||||||
|
val id = (itemObj["id"] as? JsonPrimitive)?.content ?: return@mapNotNull null
|
||||||
|
val contentStr =
|
||||||
|
(itemObj["content"] as? JsonPrimitive)?.content
|
||||||
|
?: (itemObj["text"] as? JsonPrimitive)?.content ?: ""
|
||||||
|
val status = (itemObj["status"] as? JsonPrimitive)?.content ?: "pending"
|
||||||
|
TodoItem(id = id, content = contentStr, status = status)
|
||||||
|
}.takeIf { it.isNotEmpty() }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hydrateTodosFromMessages(messages: List<ChatMessage>): List<TodoItem> {
|
||||||
|
for (msg in messages.asReversed()) {
|
||||||
|
if (msg.role == MessageRole.TOOL && (msg.toolName == "todo" || msg.content.contains("\"todos\""))) {
|
||||||
|
val todos = extractTodosFromJson(msg.content)
|
||||||
|
if (!todos.isNullOrEmpty()) {
|
||||||
|
return todos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import com.m57.hermescontrol.ui.chat.ChatViewModel
|
|||||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
|
||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,7 +46,6 @@ fun ChatMessageList(
|
|||||||
lastAnimatedMessageId: String?,
|
lastAnimatedMessageId: String?,
|
||||||
onLastAnimatedMessageIdChange: (String?) -> Unit,
|
onLastAnimatedMessageIdChange: (String?) -> Unit,
|
||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
subagentIndicators: List<SubagentIndicator> = emptyList(),
|
|
||||||
clarifyRequest: ClarifyUi? = null,
|
clarifyRequest: ClarifyUi? = null,
|
||||||
onRespondClarify: ((String) -> Unit)? = null,
|
onRespondClarify: ((String) -> Unit)? = null,
|
||||||
onDismissClarify: (() -> Unit)? = null,
|
onDismissClarify: (() -> Unit)? = null,
|
||||||
@@ -145,14 +143,6 @@ fun ChatMessageList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subagent indicators — SubagentCard replaces SubagentIndicatorRow
|
|
||||||
items(
|
|
||||||
items = subagentIndicators,
|
|
||||||
key = { indicator -> "subagent-${indicator.subagentId ?: indicator.goal ?: indicator.type}" },
|
|
||||||
) { indicator ->
|
|
||||||
SubagentCard(indicator = indicator)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clarify bubble — rendered at the very bottom
|
// Clarify bubble — rendered at the very bottom
|
||||||
if (clarifyRequest != null) {
|
if (clarifyRequest != null) {
|
||||||
item(key = "clarify_bubble") {
|
item(key = "clarify_bubble") {
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
|
import androidx.compose.material.icons.filled.ElectricBolt
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||||
|
import com.m57.hermescontrol.ui.chat.TodoItem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact sticky progress indicator displayed below the top app bar
|
||||||
|
* when background subagents or agent todos are in progress.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun StickySubagentBar(
|
||||||
|
indicators: List<SubagentIndicator> = emptyList(),
|
||||||
|
todos: List<TodoItem> = emptyList(),
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val activeSubagents = indicators.count { it.isRunning }
|
||||||
|
val activeTodos = todos.count { it.isInProgress }
|
||||||
|
val isVisible = activeSubagents > 0 || activeTodos > 0
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isVisible,
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically(),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.testTag("sticky_subagent_bar"),
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
shadowElevation = 2.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ElectricBolt,
|
||||||
|
contentDescription = "Active Tasks",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
val labelText =
|
||||||
|
when {
|
||||||
|
activeSubagents > 0 && activeTodos > 0 -> {
|
||||||
|
"$activeSubagents Subagents · $activeTodos Task Active"
|
||||||
|
}
|
||||||
|
activeSubagents > 0 -> {
|
||||||
|
val activeSub = indicators.firstOrNull { it.isRunning }
|
||||||
|
val goalText = activeSub?.goal?.takeIf { it.isNotBlank() }
|
||||||
|
val countStr =
|
||||||
|
if (activeSubagents == 1) {
|
||||||
|
"1 Subagent Active"
|
||||||
|
} else {
|
||||||
|
"$activeSubagents Subagents Active"
|
||||||
|
}
|
||||||
|
if (goalText != null) "$countStr · $goalText" else countStr
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val activeTodo = todos.firstOrNull { it.isInProgress }
|
||||||
|
val todoText = activeTodo?.content?.takeIf { it.isNotBlank() }
|
||||||
|
if (todoText != null) "Task In Progress: $todoText" else "1 Task In Progress"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = labelText,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Inspect",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = "Inspect",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+289
@@ -0,0 +1,289 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ElectricBolt
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||||
|
import com.m57.hermescontrol.ui.chat.TodoItem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspection sheet displaying active & completed subagent tasks and agent todos.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SubagentInspectionSheet(
|
||||||
|
indicators: List<SubagentIndicator> = emptyList(),
|
||||||
|
todos: List<TodoItem> = emptyList(),
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
modifier = modifier.testTag("subagent_inspection_sheet"),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ElectricBolt,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Task & Plan Inspection",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Close",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
if (indicators.isEmpty() && todos.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "No active tasks or plan items.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 16.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
if (todos.isNotEmpty()) {
|
||||||
|
item(key = "todos_header") {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "AGENT PLAN",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemsIndexed(
|
||||||
|
items = todos,
|
||||||
|
key = { index, todo -> "todo-${todo.id}_$index" },
|
||||||
|
) { _, todo ->
|
||||||
|
TodoInspectionCard(todo = todo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indicators.isNotEmpty()) {
|
||||||
|
if (todos.isNotEmpty()) {
|
||||||
|
item(key = "subagents_header") {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ElectricBolt,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "SUBAGENTS",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemsIndexed(
|
||||||
|
items = indicators,
|
||||||
|
key = { index, indicator ->
|
||||||
|
"subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}"
|
||||||
|
},
|
||||||
|
) { _, indicator ->
|
||||||
|
InspectionItemCard(indicator = indicator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TodoInspectionCard(todo: TodoItem) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
val statusSymbol =
|
||||||
|
when {
|
||||||
|
todo.isCompleted -> "✅"
|
||||||
|
todo.isInProgress -> "⚡"
|
||||||
|
todo.isCancelled -> "❌"
|
||||||
|
else -> "⭕"
|
||||||
|
}
|
||||||
|
Text(text = statusSymbol, fontSize = 16.sp)
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
text = todo.content,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None,
|
||||||
|
),
|
||||||
|
fontWeight = if (todo.isInProgress) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color =
|
||||||
|
if (todo.isCompleted) {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InspectionItemCard(indicator: SubagentIndicator) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
val statusSymbol =
|
||||||
|
when {
|
||||||
|
indicator.isComplete -> "✅"
|
||||||
|
indicator.isFailed -> "❌"
|
||||||
|
else -> "⚡"
|
||||||
|
}
|
||||||
|
Text(text = statusSymbol, fontSize = 16.sp)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
val taskIndexStr = indicator.taskIndex?.let { "#$it " } ?: ""
|
||||||
|
Text(
|
||||||
|
text = "$taskIndexStr${indicator.goal ?: "Subagent Task"}",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!indicator.summary.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Summary: ${indicator.summary}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indicator.logs.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "LIVE TRANSCRIPT",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
)
|
||||||
|
indicator.logs.forEach { logLine ->
|
||||||
|
Text(
|
||||||
|
text = "› ${logLine.text}",
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
),
|
||||||
|
color =
|
||||||
|
if (logLine.isError) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
|
|||||||
|
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ChatWsEventReducerTest {
|
class ChatWsEventReducerTest {
|
||||||
@@ -161,7 +162,7 @@ class ChatWsEventReducerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testSubagentComplete_removesIndicator() {
|
fun testSubagentComplete_updatesIndicatorToCompleted() {
|
||||||
val initialIndicator =
|
val initialIndicator =
|
||||||
SubagentIndicator(
|
SubagentIndicator(
|
||||||
type = "subagent.start",
|
type = "subagent.start",
|
||||||
@@ -196,7 +197,12 @@ class ChatWsEventReducerTest {
|
|||||||
currentSessionId = "session-1",
|
currentSessionId = "session-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(0, result.state.subagentIndicators.size)
|
assertEquals(1, result.state.subagentIndicators.size)
|
||||||
|
val indicator = result.state.subagentIndicators.first()
|
||||||
|
assertEquals("subagent.complete", indicator.type)
|
||||||
|
assertEquals("completed", indicator.status)
|
||||||
|
assertEquals("done", indicator.summary)
|
||||||
|
assertTrue(indicator.isComplete)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -233,4 +239,140 @@ class ChatWsEventReducerTest {
|
|||||||
assertEquals(ToolStatus.RUNNING, updatedMessage.toolStatus)
|
assertEquals(ToolStatus.RUNNING, updatedMessage.toolStatus)
|
||||||
assertEquals(null, updatedMessage.progressPreview)
|
assertEquals(null, updatedMessage.progressPreview)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSubagentEvent_accumulatesLiveTranscriptLogs() {
|
||||||
|
val state =
|
||||||
|
ChatUiState(
|
||||||
|
currentSessionId = "session-1",
|
||||||
|
subagentIndicators = emptyList(),
|
||||||
|
)
|
||||||
|
val startEvent =
|
||||||
|
WsEvent.SubagentEvent(
|
||||||
|
type = "subagent.start",
|
||||||
|
sessionId = "session-1",
|
||||||
|
payload =
|
||||||
|
mapOf(
|
||||||
|
"subagent_id" to "sub-1",
|
||||||
|
"goal" to "research api",
|
||||||
|
"text" to "Initializing subagent",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val res1 =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
state = state,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
event = startEvent,
|
||||||
|
currentSessionId = "session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
val progressEvent =
|
||||||
|
WsEvent.SubagentEvent(
|
||||||
|
type = "subagent.progress",
|
||||||
|
sessionId = "session-1",
|
||||||
|
payload =
|
||||||
|
mapOf(
|
||||||
|
"subagent_id" to "sub-1",
|
||||||
|
"text" to "Fetching documentation",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val res2 =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
state = res1.state,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
event = progressEvent,
|
||||||
|
currentSessionId = "session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
val indicator = res2.state.subagentIndicators.first()
|
||||||
|
assertEquals(2, indicator.logs.size)
|
||||||
|
assertEquals("Initializing subagent", indicator.logs[0].text)
|
||||||
|
assertEquals("Fetching documentation", indicator.logs[1].text)
|
||||||
|
assertTrue(indicator.isRunning)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMessageStart_prunesCompletedSubagents() {
|
||||||
|
val completedSubagent =
|
||||||
|
SubagentIndicator(
|
||||||
|
type = "subagent.complete",
|
||||||
|
goal = "finished task",
|
||||||
|
subagentId = "sub-1",
|
||||||
|
status = "completed",
|
||||||
|
)
|
||||||
|
val state =
|
||||||
|
ChatUiState(
|
||||||
|
currentSessionId = "session-1",
|
||||||
|
subagentIndicators = listOf(completedSubagent),
|
||||||
|
)
|
||||||
|
val startEvent = WsEvent.MessageStart(sessionId = "session-1")
|
||||||
|
|
||||||
|
val result =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
state = state,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
event = startEvent,
|
||||||
|
currentSessionId = "session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result.state.subagentIndicators.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testToolStart_extractsAgentTodos() {
|
||||||
|
val state = ChatUiState(currentSessionId = "session-1")
|
||||||
|
val todoEvent =
|
||||||
|
WsEvent.ToolStart(
|
||||||
|
name = "todo",
|
||||||
|
sessionId = "session-1",
|
||||||
|
data =
|
||||||
|
mapOf(
|
||||||
|
"todos" to
|
||||||
|
listOf(
|
||||||
|
mapOf("id" to "1", "content" to "Inspect repo", "status" to "completed"),
|
||||||
|
mapOf("id" to "2", "content" to "Implement feature", "status" to "in_progress"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
state = state,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
event = todoEvent,
|
||||||
|
currentSessionId = "session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(2, result.state.todos.size)
|
||||||
|
assertEquals("Inspect repo", result.state.todos[0].content)
|
||||||
|
assertTrue(result.state.todos[0].isCompleted)
|
||||||
|
assertEquals("Implement feature", result.state.todos[1].content)
|
||||||
|
assertTrue(result.state.todos[1].isInProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHydrateTodosFromMessages_parsesStoredToolMessage() {
|
||||||
|
val todoMessage =
|
||||||
|
ChatMessage(
|
||||||
|
role = MessageRole.TOOL,
|
||||||
|
toolName = "todo",
|
||||||
|
content = """{"todos":[{"id":"a","content":"Write tests","status":"completed"}]}""",
|
||||||
|
)
|
||||||
|
val state = ChatUiState(messages = listOf(todoMessage), currentSessionId = "session-1")
|
||||||
|
val event = WsEvent.MessageToken(token = "hello", sessionId = "session-1")
|
||||||
|
|
||||||
|
val result =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
state = state,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
event = event,
|
||||||
|
currentSessionId = "session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, result.state.todos.size)
|
||||||
|
assertEquals("Write tests", result.state.todos[0].content)
|
||||||
|
assertTrue(result.state.todos[0].isCompleted)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user