diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheetTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheetTest.kt new file mode 100644 index 00000000..2acea0b8 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheetTest.kt @@ -0,0 +1,121 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.ui.chat.SubagentIndicator +import com.m57.hermescontrol.ui.chat.SubagentTranscriptUiState +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class SubagentInspectionSheetTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testSubagentInspectionSheet_displaysSubagentAndTogglesTranscript() { + var toggledSubagentId = "" + val indicator = + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1089", + goal = "Analyze server logs", + status = "running", + model = "claude-sonnet", + durationSeconds = 12.4, + ) + + composeTestRule.setContent { + SubagentInspectionSheet( + indicators = listOf(indicator), + inspectingSubagentId = null, + subagentTranscript = null, + onToggleTranscript = { toggledSubagentId = it }, + onDismiss = {}, + ) + } + + composeTestRule.onNodeWithTag("subagent_inspection_sheet").assertIsDisplayed() + composeTestRule.onNodeWithText("Analyze server logs").assertIsDisplayed() + composeTestRule.onNodeWithTag("subagent_transcript_toggle_sub-1089").assertIsDisplayed() + + composeTestRule.onNodeWithTag("subagent_transcript_toggle_sub-1089").performClick() + assertEquals("sub-1089", toggledSubagentId) + } + + @Test + fun testSubagentInspectionSheet_displaysTranscriptContentAndTruncation() { + val indicator = + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1089", + goal = "Analyze server logs", + status = "running", + ) + val transcript = + SubagentTranscriptUiState( + subagentId = "sub-1089", + text = "Fetched 100 log lines successfully.", + isTruncated = true, + isLoading = false, + ) + + composeTestRule.setContent { + SubagentInspectionSheet( + indicators = listOf(indicator), + inspectingSubagentId = "sub-1089", + subagentTranscript = transcript, + onToggleTranscript = {}, + onDismiss = {}, + ) + } + + composeTestRule.onNodeWithTag("subagent_transcript_container").assertIsDisplayed() + composeTestRule.onNodeWithTag("subagent_transcript_text").assertIsDisplayed() + composeTestRule.onNodeWithText("Fetched 100 log lines successfully.").assertIsDisplayed() + composeTestRule.onNodeWithText("Rolling 16KB tail (older output truncated)").assertIsDisplayed() + } + + @Test + fun testSubagentInspectionSheet_displaysErrorAndTriggersRetry() { + var retried = false + val indicator = + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1089", + goal = "Analyze server logs", + status = "running", + ) + val transcript = + SubagentTranscriptUiState( + subagentId = "sub-1089", + text = "", + error = "Connection timeout", + isLoading = false, + ) + + composeTestRule.setContent { + SubagentInspectionSheet( + indicators = listOf(indicator), + inspectingSubagentId = "sub-1089", + subagentTranscript = transcript, + onToggleTranscript = {}, + onRetryTranscript = { retried = true }, + onDismiss = {}, + ) + } + + composeTestRule.onNodeWithText("Connection timeout").assertIsDisplayed() + composeTestRule.onNodeWithTag("subagent_transcript_retry").assertIsDisplayed() + composeTestRule.onNodeWithTag("subagent_transcript_retry").performClick() + assertEquals(true, retried) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SubagentModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SubagentModels.kt new file mode 100644 index 00000000..c9f46819 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SubagentModels.kt @@ -0,0 +1,52 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * Item returned in the [SubagentListResponse] from `subagent.list` (issue #1089). + */ +@Serializable +data class SubagentListItem( + @SerialName("subagent_id") val subagentId: String = "", + val goal: String? = null, + val status: String? = null, + val model: String? = null, + @SerialName("elapsed_seconds") val elapsedSeconds: Double? = null, + @SerialName("started_at") val startedAt: Double? = null, + @SerialName("parent_id") val parentId: String? = null, + val depth: Int? = null, + @SerialName("delegation_id") val delegationId: String? = null, + @SerialName("tool_count") val toolCount: Int? = null, + @SerialName("last_tool") val lastTool: String? = null, + @SerialName("accepting_steer") val acceptingSteer: Boolean? = null, +) + +/** + * Response returned from `subagent.list` (issue #1089). + */ +@Serializable +data class SubagentListResponse( + val subagents: List = emptyList(), + val delegations: List = emptyList(), +) + +/** + * Response returned from `subagent.tail` (issue #1089). + * + * Backend returns either `text` (desktop/TUI gateway) or `tail` (contract). + * [content] returns the authoritative transcript text from whichever field is present. + */ +@Serializable +data class SubagentTailResponse( + @SerialName("subagent_id") val subagentId: String = "", + val tail: String? = null, + val text: String? = null, + val available: Boolean? = null, + val truncated: Boolean? = null, + @SerialName("bytes_read") val bytesRead: Long? = null, +) { + /** Returns authoritative transcript text across backend schema variants. */ + fun content(): String = text ?: tail ?: "" +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt new file mode 100644 index 00000000..45a9ac37 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt @@ -0,0 +1,138 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.model.SubagentListResponse +import com.m57.hermescontrol.data.model.SubagentTailResponse +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.serializer + +/** + * WebSocket RPC repository for subagent operations: `subagent.list` and `subagent.tail` (issue #1089). + */ +object SubagentRepository { + private const val TAG = "SubagentRepository" + + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + } + + /** + * Request the current active subagent roster for a given [sessionId]. + * + * Returns null if RPC fails or is unsupported by server. + */ + suspend fun listSubagents(sessionId: String): SubagentListResponse? { + if (sessionId.isBlank()) return null + return try { + val result = + HermesWsClient + .request( + WsMethods.SUBAGENT_LIST, + mapOf("session_id" to sessionId), + ).await() + decode(result) + } catch (e: Exception) { + Log.w(TAG, "subagent.list request failed for session $sessionId: ${e.message}") + null + } + } + + /** + * Request a rolling live transcript tail for [subagentId]. + * + * Supplies [sessionId] when available so gateway authority checks pass cleanly. + * Throws or returns null if unavailable. + */ + suspend fun tailSubagent( + sessionId: String, + subagentId: String, + maxBytes: Int = 16384, + ): SubagentTailResponse? { + if (subagentId.isBlank()) return null + val params = + mutableMapOf( + "subagent_id" to subagentId, + "max_bytes" to maxBytes, + ) + if (sessionId.isNotBlank()) { + params["session_id"] = sessionId + } + val result = HermesWsClient.request(WsMethods.SUBAGENT_TAIL, params).await() + return decode(result) + } + + @Suppress("UNCHECKED_CAST") + internal inline fun decode(result: Any?): T? { + if (result == null) return null + val element: JsonElement = + when (result) { + is JsonElement -> { + result + } + + is Map<*, *> -> { + anyToJsonElement(result) + } + + is List<*> -> { + JsonArray(result.map { anyToJsonElement(it) }) + } + + else -> { + val str = result.toString() + try { + json.parseToJsonElement(str) + } catch (_: Exception) { + JsonPrimitive(str) + } + } + } + return json.decodeFromJsonElement(serializer(), element) + } + + @Suppress("UNCHECKED_CAST") + internal fun anyToJsonElement(value: Any?): JsonElement = + when (value) { + null -> { + JsonNull + } + + is JsonElement -> { + value + } + + is Map<*, *> -> { + JsonObject( + (value as Map).mapValues { (_, v) -> anyToJsonElement(v) }, + ) + } + + is List<*> -> { + JsonArray(value.map { anyToJsonElement(it) }) + } + + is String -> { + JsonPrimitive(value) + } + + is Boolean -> { + JsonPrimitive(value) + } + + is Number -> { + JsonPrimitive(value) + } + + else -> { + JsonPrimitive(value.toString()) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 586f146a..088ebccf 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -83,6 +83,14 @@ object WsMethods { /** Kill a single background process (scoped to the active session). */ const val PROCESS_KILL = "process.kill" + // ── Subagents (issue #1089) ─────────────────────────────────────────── + + /** List active subagents and delegations for a session. */ + const val SUBAGENT_LIST = "subagent.list" + + /** Rolling tail snapshot of a subagent's live execution transcript. */ + const val SUBAGENT_TAIL = "subagent.tail" + // ── Billing / subscription (issue #628) ───────────────────────────── // Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e). // `credits.view` was REMOVED upstream; these replace it. `usage.bars` diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index b7afe3d6..8239f426 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -128,6 +128,7 @@ data class SubagentIndicator( val logs: List = emptyList(), val durationSeconds: Double? = null, val model: String? = null, + val lastEventTimestamp: Long = 0L, ) { val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done" val isFailed: Boolean get() = status == "failed" || status == "interrupted" @@ -137,6 +138,20 @@ data class SubagentIndicator( val isRunning: Boolean get() = !isComplete && !isFailed && !isCancelled && !isQueued } +/** + * State for inspecting a live subagent's rolling execution transcript (issue #1089). + */ +data class SubagentTranscriptUiState( + val subagentId: String, + val text: String = "", + val isLoading: Boolean = false, + val isTruncated: Boolean = false, + val bytesRead: Long? = null, + val error: String? = null, +) { + val isEmpty: Boolean get() = text.isEmpty() && !isLoading && error == null +} + enum class MessageRole { USER, ASSISTANT, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 606180d3..221b0cac 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -291,6 +291,11 @@ fun ChatScreen( var lastAnimatedMessageId by rememberSaveable { mutableStateOf(null) } var showReloginDialog by rememberSaveable { mutableStateOf(false) } var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(showSubagentInspectionSheet) { + if (showSubagentInspectionSheet) { + viewModel.hydrateSubagents() + } + } var viewingImage by rememberSaveable { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } val isDark = isSystemInDarkTheme() @@ -732,9 +737,16 @@ fun ChatScreen( SubagentInspectionSheet( indicators = state.subagentIndicators, todos = state.todos, + inspectingSubagentId = state.inspectingSubagentId, + subagentTranscript = state.subagentTranscript, + onToggleTranscript = { subagentId -> viewModel.toggleSubagentTranscript(subagentId) }, + onRetryTranscript = { viewModel.retrySubagentTranscript() }, onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) }, onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) }, - onDismiss = { showSubagentInspectionSheet = false }, + onDismiss = { + showSubagentInspectionSheet = false + viewModel.closeSubagentTranscript() + }, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt new file mode 100644 index 00000000..e8801097 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt @@ -0,0 +1,305 @@ +package com.m57.hermescontrol.ui.chat + +import android.util.Log +import com.m57.hermescontrol.data.model.SubagentListItem +import com.m57.hermescontrol.data.ws.SubagentRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Delegate managing subagent roster hydration via `subagent.list` and live transcript + * tail polling via `subagent.tail` (issue #1089). + */ +class ChatSubagentsDelegate( + private val uiState: MutableStateFlow, + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher, + private val runtimeSessionId: () -> String?, + private val subagentRepository: SubagentRepository = SubagentRepository, + private val pollingIntervalMs: Long = 1500L, +) { + companion object { + private const val TAG = "ChatSubagentsDelegate" + const val SUBAGENT_TAIL_MAX_BYTES = 16384 + } + + private var pollingJob: Job? = null + private var hydrationJob: Job? = null + + /** + * Request the authoritative subagent roster from the gateway and merge it into [ChatUiState]. + * + * A failed request degrades gracefully without clearing existing push-event indicators. + */ + fun hydrateSubagents(sessionId: String? = runtimeSessionId()) { + val targetSession = sessionId?.trim() + if (targetSession.isNullOrEmpty()) return + + val requestTime = System.currentTimeMillis() + hydrationJob?.cancel() + hydrationJob = + scope.launch(ioDispatcher) { + try { + val response = subagentRepository.listSubagents(targetSession) ?: return@launch + uiState.update { current -> + val merged = mergeSubagentList(current.subagentIndicators, response.subagents, requestTime) + current.copy(subagentIndicators = merged) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Failed to hydrate subagents for session $targetSession: ${e.message}") + } + } + } + + /** + * Toggle transcript inspection for [subagentId]. + * + * If already inspecting [subagentId], closes the transcript view; otherwise opens it. + */ + fun toggleSubagentTranscript(subagentId: String) { + if (uiState.value.inspectingSubagentId == subagentId) { + closeSubagentTranscript() + } else { + inspectSubagentTranscript(subagentId) + } + } + + /** + * Start inspecting a live subagent's execution transcript, initiating bounded polling. + */ + fun inspectSubagentTranscript(subagentId: String) { + if (subagentId.isBlank()) return + pollingJob?.cancel() + + uiState.update { current -> + current.copy( + inspectingSubagentId = subagentId, + subagentTranscript = + SubagentTranscriptUiState( + subagentId = subagentId, + isLoading = true, + ), + ) + } + + pollingJob = + scope.launch(ioDispatcher) { + val sessionId = runtimeSessionId() ?: return@launch + pollTranscriptLoop(sessionId, subagentId) + } + } + + /** + * Retry loading the transcript for the currently inspected subagent. + */ + fun retryTranscript() { + val subagentId = uiState.value.inspectingSubagentId ?: return + inspectSubagentTranscript(subagentId) + } + + /** + * Stop transcript polling and dismiss the transcript disclosure. + */ + fun closeSubagentTranscript() { + pollingJob?.cancel() + pollingJob = null + uiState.update { current -> + current.copy( + inspectingSubagentId = null, + subagentTranscript = null, + ) + } + } + + private suspend fun pollTranscriptLoop( + sessionId: String, + subagentId: String, + ) { + while (scope.isActive) { + try { + val tailResponse = + subagentRepository.tailSubagent( + sessionId = sessionId, + subagentId = subagentId, + maxBytes = SUBAGENT_TAIL_MAX_BYTES, + ) + + if (tailResponse != null) { + val content = tailResponse.content() + val truncated = tailResponse.truncated ?: false + val bytesRead = tailResponse.bytesRead + + uiState.update { current -> + if (current.inspectingSubagentId != subagentId) return@update current + val prev = current.subagentTranscript + if (prev == null || prev.text != content || prev.isLoading || prev.error != null) { + current.copy( + subagentTranscript = + SubagentTranscriptUiState( + subagentId = subagentId, + text = content, + isLoading = false, + isTruncated = truncated, + bytesRead = bytesRead, + error = null, + ), + ) + } else { + current + } + } + } else { + uiState.update { current -> + if (current.inspectingSubagentId != subagentId) return@update current + val prev = current.subagentTranscript + if (prev == null || prev.text.isEmpty()) { + current.copy( + subagentTranscript = + SubagentTranscriptUiState( + subagentId = subagentId, + isLoading = false, + error = "Unable to load transcript", + ), + ) + } else { + current.copy(subagentTranscript = prev.copy(isLoading = false)) + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Error fetching transcript tail for subagent $subagentId: ${e.message}") + uiState.update { current -> + if (current.inspectingSubagentId != subagentId) return@update current + val prev = current.subagentTranscript + if (prev == null || prev.text.isEmpty()) { + current.copy( + subagentTranscript = + SubagentTranscriptUiState( + subagentId = subagentId, + isLoading = false, + error = e.message ?: "Unable to load transcript", + ), + ) + } else { + current.copy(subagentTranscript = prev.copy(isLoading = false)) + } + } + } + + // Stop polling once the subagent reaches a terminal state (one final refresh is enough) + val currentIndicator = uiState.value.subagentIndicators.firstOrNull { it.subagentId == subagentId } + if (currentIndicator != null && !currentIndicator.isRunning) { + break + } + + delay(pollingIntervalMs) + } + } + + /** + * Merges snapshot items from `subagent.list` with current indicators. + * + * Rules: + * - `subagent_id` is the primary identity. + * - Protects against races: if an indicator received a push event with timestamp > [requestTime], + * its push-event status/type is preserved. + * - Preserves local details (logs, taskIndex, taskCount, summary) not provided in the snapshot. + * - Does not discard locally known indicators absent from the snapshot. + */ + internal fun mergeSubagentList( + current: List, + listItems: List, + requestTime: Long, + ): List { + val merged = current.toMutableList() + + for (item in listItems) { + val subagentId = item.subagentId.trim() + if (subagentId.isEmpty() && item.goal.isNullOrBlank()) continue + + val idx = + if (subagentId.isNotEmpty()) { + merged.indexOfFirst { it.subagentId == subagentId } + } else { + merged.indexOfFirst { it.subagentId == null && it.goal == item.goal } + } + + if (idx >= 0) { + val existing = merged[idx] + val isPushEventNewer = existing.lastEventTimestamp > requestTime + + val finalStatus = + if (isPushEventNewer) { + existing.status ?: item.status ?: "running" + } else if (existing.isComplete || existing.isFailed || existing.isCancelled) { + // Terminal state guard: don't revert to running + if (item.status == "completed" || item.status == "failed" || + item.status == "cancelled" || item.status == "stopped" + ) { + item.status + } else { + existing.status + } + } else { + item.status ?: existing.status ?: "running" + } + + val finalType = + if (isPushEventNewer) { + existing.type + } else if (finalStatus == "completed" || finalStatus == "done" || existing.isComplete) { + "subagent.complete" + } else if (existing.type.isNotBlank()) { + existing.type + } else { + "subagent.progress" + } + + merged[idx] = + existing.copy( + type = finalType, + goal = item.goal ?: existing.goal, + status = finalStatus, + subagentId = if (subagentId.isNotEmpty()) subagentId else existing.subagentId, + durationSeconds = + if (isPushEventNewer) { + existing.durationSeconds ?: item.elapsedSeconds + } else { + item.elapsedSeconds ?: existing.durationSeconds + }, + model = item.model ?: existing.model, + lastEventTimestamp = maxOf(existing.lastEventTimestamp, requestTime), + ) + } else { + val status = item.status ?: "running" + val isComp = status == "completed" || status == "done" + val type = if (isComp) "subagent.complete" else "subagent.progress" + + merged.add( + SubagentIndicator( + type = type, + goal = item.goal, + status = status, + subagentId = subagentId.takeIf { it.isNotEmpty() }, + durationSeconds = item.elapsedSeconds, + model = item.model, + lastEventTimestamp = requestTime, + ), + ) + } + } + + return merged + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index b0806a59..52438642 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -164,6 +164,10 @@ data class ChatUiState( val btwState: BtwUiState? = null, /** Subagent delegation indicators (issue #538) — transient UI state. */ val subagentIndicators: List = emptyList(), + /** Currently inspected subagent ID for live transcript tail (issue #1089). */ + val inspectingSubagentId: String? = null, + /** Transient live transcript tail state for the inspected subagent (issue #1089). */ + val subagentTranscript: SubagentTranscriptUiState? = null, /** Agent todo / plan items (issue #736). */ val todos: List = emptyList(), // Session resume recovery (desktop parity: bounded auto-retry + error UI) @@ -473,6 +477,14 @@ class ChatViewModel( trackRequest = { id, method -> trackRequest(id, method) }, ) + private val subagentsDelegate = + ChatSubagentsDelegate( + uiState = _uiState, + scope = viewModelScope, + ioDispatcher = ioDispatcher, + runtimeSessionId = { runtimeSessionId ?: _uiState.value.currentSessionId }, + ) + private val streamingController = ChatStreamingController( scope = viewModelScope, @@ -534,6 +546,7 @@ class ChatViewModel( status == ConnectionStatus.AUTH_EXPIRED ) { _uiState.update { it.copy(isLoading = false) } + subagentsDelegate.closeSubagentTranscript() // The runtime session id is only valid while the socket // owns it — a dropped connection may mean the gateway // closed/pruned the session (or restarted, wiping the @@ -1139,6 +1152,7 @@ class ChatViewModel( val generation = request?.generation ?: sessionGeneration resumedGeneration = generation finishResumeWhenHydrated(generation) + subagentsDelegate.hydrateSubagents(runtimeSessionId ?: sessionId) // Reconnect replay: resume payload can carry `pending_approval` // (server `_session_info_payload`); surface it, then ask for // the full queue in case more are parked. @@ -2182,6 +2196,22 @@ class ChatViewModel( } } + fun hydrateSubagents(sessionId: String? = null) { + subagentsDelegate.hydrateSubagents(sessionId ?: runtimeSessionId ?: _uiState.value.currentSessionId) + } + + fun toggleSubagentTranscript(subagentId: String) { + subagentsDelegate.toggleSubagentTranscript(subagentId) + } + + fun retrySubagentTranscript() { + subagentsDelegate.retryTranscript() + } + + fun closeSubagentTranscript() { + subagentsDelegate.closeSubagentTranscript() + } + fun createNewSession(setLoading: Boolean = true) { // A fresh create has no persisted row until the first prompt. sessionHasServerPresence = false @@ -3451,6 +3481,7 @@ class ChatViewModel( override fun onCleared() { super.onCleared() + subagentsDelegate.closeSubagentTranscript() // PERF-16: Don't disconnect the global HermesWsClient singleton when // leaving the Chat screen — it's used by background notification reply. } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 28b71c24..068e4b61 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -946,6 +946,7 @@ object ChatWsEventReducer { logs = trimmedLogs, durationSeconds = durationSeconds ?: (if (idx >= 0) indicators[idx].durationSeconds else null), model = model ?: (if (idx >= 0) indicators[idx].model else null), + lastEventTimestamp = System.currentTimeMillis(), ) if (idx >= 0) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt index b820b585..ceacfe05 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt @@ -4,20 +4,25 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues 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.heightIn 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.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.AltRoute import androidx.compose.material.icons.automirrored.filled.FormatListBulleted @@ -32,11 +37,14 @@ import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.HourglassEmpty import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Terminal import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton @@ -45,6 +53,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -63,6 +72,7 @@ import androidx.compose.ui.unit.sp import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.chat.SubagentIndicator +import com.m57.hermescontrol.ui.chat.SubagentTranscriptUiState import com.m57.hermescontrol.ui.chat.TodoItem /** @@ -73,6 +83,10 @@ import com.m57.hermescontrol.ui.chat.TodoItem fun SubagentInspectionSheet( indicators: List = emptyList(), todos: List = emptyList(), + inspectingSubagentId: String? = null, + subagentTranscript: SubagentTranscriptUiState? = null, + onToggleTranscript: ((String) -> Unit)? = null, + onRetryTranscript: (() -> Unit)? = null, onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null, onStopSubagent: ((SubagentIndicator) -> Unit)? = null, onDismiss: () -> Unit, @@ -197,7 +211,7 @@ fun SubagentInspectionSheet( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "SUBAGENTS", + text = stringResource(R.string.subagent_subagents_header).uppercase(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, @@ -211,8 +225,14 @@ fun SubagentInspectionSheet( "subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}" }, ) { _, indicator -> + val isInspectingThis = + indicator.subagentId != null && indicator.subagentId == inspectingSubagentId InspectionItemCard( indicator = indicator, + isInspectingTranscript = isInspectingThis, + transcriptState = if (isInspectingThis) subagentTranscript else null, + onToggleTranscript = onToggleTranscript, + onRetryTranscript = onRetryTranscript, onSteer = if (onSteerSubagent != null) { { message -> onSteerSubagent(indicator, message) } @@ -448,6 +468,10 @@ private fun TodoInspectionCard( @Composable private fun InspectionItemCard( indicator: SubagentIndicator, + isInspectingTranscript: Boolean = false, + transcriptState: SubagentTranscriptUiState? = null, + onToggleTranscript: ((String) -> Unit)? = null, + onRetryTranscript: (() -> Unit)? = null, onSteer: ((String) -> Unit)? = null, onStop: (() -> Unit)? = null, ) { @@ -619,6 +643,44 @@ private fun InspectionItemCard( } } + // Full Rolling Transcript Disclosure (issue #1089) + if (!indicator.subagentId.isNullOrBlank() && onToggleTranscript != null) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { onToggleTranscript(indicator.subagentId) }, + modifier = + Modifier + .fillMaxWidth() + .testTag("subagent_transcript_toggle_${indicator.subagentId}"), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { + Icon( + imageVector = if (isInspectingTranscript) Icons.Filled.ExpandLess else Icons.Filled.Terminal, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = + if (isInspectingTranscript) { + stringResource(R.string.subagent_hide_transcript) + } else { + stringResource(R.string.subagent_inspect_transcript) + }, + style = MaterialTheme.typography.labelMedium, + ) + } + + AnimatedVisibility(visible = isInspectingTranscript) { + Column(modifier = Modifier.padding(top = 8.dp)) { + SubagentTranscriptView( + state = transcriptState, + onRetry = onRetryTranscript, + ) + } + } + } + // Interactive Controls for active subagent (issue #1030) if (indicator.isRunning || indicator.isSteered) { Spacer(modifier = Modifier.height(10.dp)) @@ -721,3 +783,175 @@ private fun InspectionItemCard( } } } + +@Composable +private fun SubagentTranscriptView( + state: SubagentTranscriptUiState?, + onRetry: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth().testTag("subagent_transcript_container"), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + ) { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + when { + state == null || (state.isLoading && state.text.isEmpty()) -> { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.subagent_transcript_loading), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + state.error != null && state.text.isEmpty() -> { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = state.error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + if (onRetry != null) { + OutlinedButton( + onClick = onRetry, + modifier = Modifier.testTag("subagent_transcript_retry"), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + ) { + Text( + text = stringResource(R.string.subagent_transcript_retry), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + + state.isEmpty -> { + Text( + text = stringResource(R.string.subagent_transcript_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .testTag("subagent_transcript_empty"), + ) + } + + else -> { + // Header badges (truncation note & background loading indicator) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.subagent_live_transcript), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.outline, + fontSize = 9.sp, + ) + + if (state.isTruncated) { + Text( + text = stringResource(R.string.subagent_transcript_truncated), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + fontSize = 8.sp, + ) + } + } + + if (state.isLoading) { + LinearProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .height(2.dp), + color = MaterialTheme.colorScheme.primary, + ) + } + + val scrollState = rememberScrollState() + LaunchedEffect(state.text) { + scrollState.animateScrollTo(scrollState.maxValue) + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 60.dp, max = 240.dp) + .verticalScroll(scrollState), + ) { + Text( + text = state.text, + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 15.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.testTag("subagent_transcript_text"), + ) + } + + if (state.error != null && onRetry != null) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = state.error, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = onRetry, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Filled.Autorenew, + contentDescription = stringResource(R.string.subagent_transcript_retry), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e14faf5..54c24310 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1275,6 +1275,14 @@ Cancelled Failed %1$.1fs + Subagents + Inspect Transcript + Hide Transcript + Loading live transcript… + No transcript output available. + Unable to load transcript + Retry + Rolling 16KB tail (older output truncated) Auth required Checkpoint prune Config migrate diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt new file mode 100644 index 00000000..10fc76d9 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt @@ -0,0 +1,126 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.SubagentListItem +import com.m57.hermescontrol.data.model.SubagentListResponse +import com.m57.hermescontrol.data.model.SubagentTailResponse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SubagentRepositoryTest { + @Test + fun testWsMethodsConstants() { + assertEquals("subagent.list", WsMethods.SUBAGENT_LIST) + assertEquals("subagent.tail", WsMethods.SUBAGENT_TAIL) + } + + @Test + fun testSubagentListResponse_decoding_fullPayload() { + val map = + mapOf( + "subagents" to + listOf( + mapOf( + "subagent_id" to "sub-001", + "goal" to "Scrape web documentation", + "status" to "running", + "model" to "claude-3-5-sonnet", + "elapsed_seconds" to 14.5, + "started_at" to 1718000000.0, + "parent_id" to "root-session", + "depth" to 1, + "delegation_id" to "del-123", + "tool_count" to 8, + "last_tool" to "web_search", + "accepting_steer" to true, + "unexpected_future_field" to "safely ignored", + ), + ), + "delegations" to emptyList(), + ) + + val response = SubagentRepository.decode(map) + assertNotNull(response) + assertEquals(1, response?.subagents?.size) + + val item = response?.subagents?.first() + assertNotNull(item) + assertEquals("sub-001", item?.subagentId) + assertEquals("Scrape web documentation", item?.goal) + assertEquals("running", item?.status) + assertEquals("claude-3-5-sonnet", item?.model) + assertEquals(14.5, item?.elapsedSeconds) + assertEquals(1718000000.0, item?.startedAt) + assertEquals("root-session", item?.parentId) + assertEquals(1, item?.depth) + assertEquals("del-123", item?.delegationId) + assertEquals(8, item?.toolCount) + assertEquals("web_search", item?.lastTool) + assertEquals(true, item?.acceptingSteer) + } + + @Test + fun testSubagentListResponse_decoding_minimalAndMissingFields() { + val map = + mapOf( + "subagents" to + listOf( + mapOf( + "subagent_id" to "sub-002", + ), + mapOf(), + ), + ) + + val response = SubagentRepository.decode(map) + assertNotNull(response) + assertEquals(2, response?.subagents?.size) + assertEquals("sub-002", response?.subagents?.get(0)?.subagentId) + assertNull(response?.subagents?.get(0)?.goal) + assertEquals("", response?.subagents?.get(1)?.subagentId) + } + + @Test + fun testSubagentTailResponse_decoding_gatewayTextVariant() { + val map = + mapOf( + "subagent_id" to "sub-001", + "available" to true, + "text" to "Streaming subagent output line 1\nLine 2\n", + "truncated" to true, + ) + + val response = SubagentRepository.decode(map) + assertNotNull(response) + assertEquals("sub-001", response?.subagentId) + assertEquals(true, response?.available) + assertEquals("Streaming subagent output line 1\nLine 2\n", response?.content()) + assertTrue(response?.truncated == true) + } + + @Test + fun testSubagentTailResponse_decoding_contractTailVariant() { + val map = + mapOf( + "subagent_id" to "sub-002", + "tail" to "Contract tail output snippet", + "bytes_read" to 4096L, + ) + + val response = SubagentRepository.decode(map) + assertNotNull(response) + assertEquals("sub-002", response?.subagentId) + assertEquals("Contract tail output snippet", response?.content()) + assertEquals(4096L, response?.bytesRead) + assertFalse(response?.truncated == true) + } + + @Test + fun testDecode_handlesNullResult() { + val response = SubagentRepository.decode(null) + assertNull(response) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt new file mode 100644 index 00000000..3ceb8aa4 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt @@ -0,0 +1,399 @@ +package com.m57.hermescontrol.ui.chat + +import android.util.Log +import com.m57.hermescontrol.data.model.SubagentListItem +import com.m57.hermescontrol.data.model.SubagentListResponse +import com.m57.hermescontrol.data.model.SubagentTailResponse +import com.m57.hermescontrol.data.ws.SubagentRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatSubagentsDelegateTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "session-123")) + private val mockRepository = mockk() + + private lateinit var delegate: ChatSubagentsDelegate + + @Before + fun setup() { + mockkStatic(Log::class) + every { Log.w(any(), any()) } returns 0 + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + delegate = + ChatSubagentsDelegate( + uiState = uiState, + scope = testScope, + ioDispatcher = testDispatcher, + runtimeSessionId = { "session-123" }, + subagentRepository = mockRepository, + pollingIntervalMs = 1000L, + ) + } + + @After + fun tearDown() { + unmockkStatic(Log::class) + } + + @Test + fun testHydrateSubagents_whenNoPriorEvents_addsNewIndicators() = + testScope.runTest { + coEvery { mockRepository.listSubagents("session-123") } returns + SubagentListResponse( + subagents = + listOf( + SubagentListItem( + subagentId = "sub-1", + goal = "Download datasets", + status = "running", + model = "claude-sonnet", + elapsedSeconds = 5.2, + ), + SubagentListItem( + subagentId = "sub-2", + goal = "Train model", + status = "completed", + model = "gpt-4o", + elapsedSeconds = 32.0, + ), + ), + ) + + delegate.hydrateSubagents("session-123") + advanceUntilIdle() + + val indicators = uiState.value.subagentIndicators + assertEquals(2, indicators.size) + + val first = indicators[0] + assertEquals("sub-1", first.subagentId) + assertEquals("Download datasets", first.goal) + assertEquals("running", first.status) + assertEquals("subagent.progress", first.type) + assertEquals("claude-sonnet", first.model) + assertEquals(5.2, first.durationSeconds) + + val second = indicators[1] + assertEquals("sub-2", second.subagentId) + assertEquals("Train model", second.goal) + assertEquals("completed", second.status) + assertEquals("subagent.complete", second.type) + assertEquals(32.0, second.durationSeconds) + } + + @Test + fun testHydrateSubagents_deduplicatesAndMergesWithExistingPushEvent() = + testScope.runTest { + val initialLogs = listOf(SubagentLogLine(text = "Downloading chunk 1")) + uiState.value = + uiState.value.copy( + subagentIndicators = + listOf( + SubagentIndicator( + type = "subagent.progress", + goal = "Download datasets", + taskIndex = 1, + taskCount = 3, + subagentId = "sub-1", + logs = initialLogs, + lastEventTimestamp = 1000L, + ), + ), + ) + + coEvery { mockRepository.listSubagents("session-123") } returns + SubagentListResponse( + subagents = + listOf( + SubagentListItem( + subagentId = "sub-1", + goal = "Download datasets", + status = "running", + model = "claude-sonnet", + elapsedSeconds = 8.5, + ), + ), + ) + + delegate.hydrateSubagents("session-123") + advanceUntilIdle() + + val indicators = uiState.value.subagentIndicators + assertEquals(1, indicators.size) + + val merged = indicators.first() + assertEquals("sub-1", merged.subagentId) + assertEquals(1, merged.taskIndex) + assertEquals(3, merged.taskCount) + assertEquals(initialLogs, merged.logs) + assertEquals("claude-sonnet", merged.model) + assertEquals(8.5, merged.durationSeconds) + assertEquals("running", merged.status) + } + + @Test + fun testHydrateSubagents_newerPushEventWinsOverOlderInFlightSnapshot() = + testScope.runTest { + val requestTime = 1000L + val current = + listOf( + SubagentIndicator( + type = "subagent.complete", + subagentId = "sub-1", + goal = "Run benchmark", + status = "completed", + lastEventTimestamp = 2000L, // Newer than requestTime + ), + ) + + val listItems = + listOf( + SubagentListItem( + subagentId = "sub-1", + goal = "Run benchmark", + status = "running", // Older snapshot state + elapsedSeconds = 12.0, + ), + ) + + val result = delegate.mergeSubagentList(current, listItems, requestTime) + assertEquals(1, result.size) + assertEquals("completed", result.first().status) + assertEquals("subagent.complete", result.first().type) + } + + @Test + fun testHydrateSubagents_terminalStateNotRevertedToRunning() = + testScope.runTest { + val requestTime = 2000L + val current = + listOf( + SubagentIndicator( + type = "subagent.complete", + subagentId = "sub-1", + goal = "Run test suite", + status = "completed", + lastEventTimestamp = 1000L, + ), + ) + + val listItems = + listOf( + SubagentListItem( + subagentId = "sub-1", + goal = "Run test suite", + status = "running", + ), + ) + + val result = delegate.mergeSubagentList(current, listItems, requestTime) + assertEquals(1, result.size) + assertEquals("completed", result.first().status) + assertEquals("subagent.complete", result.first().type) + } + + @Test + fun testHydrateSubagents_preservesIndicatorsOmittedFromSnapshot() = + testScope.runTest { + val requestTime = 1000L + val current = + listOf( + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1", + goal = "Active worker 1", + status = "running", + ), + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-2", + goal = "Active worker 2", + status = "running", + ), + ) + + val listItems = + listOf( + SubagentListItem( + subagentId = "sub-2", + goal = "Active worker 2", + status = "running", + ), + ) + + val result = delegate.mergeSubagentList(current, listItems, requestTime) + assertEquals(2, result.size) + assertTrue(result.any { it.subagentId == "sub-1" }) + assertTrue(result.any { it.subagentId == "sub-2" }) + } + + @Test + fun testInspectSubagentTranscript_replacesTailWithoutDuplicateAppending() = + testScope.runTest { + uiState.value = + uiState.value.copy( + subagentIndicators = + listOf( + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1", + status = "running", + ), + ), + ) + + coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returnsMany + listOf( + SubagentTailResponse(subagentId = "sub-1", text = "Line 1\n", truncated = false), + SubagentTailResponse(subagentId = "sub-1", text = "Line 1\nLine 2\n", truncated = false), + ) + + delegate.inspectSubagentTranscript("sub-1") + assertEquals("sub-1", uiState.value.inspectingSubagentId) + assertTrue(uiState.value.subagentTranscript?.isLoading == true) + + // Advance time to run first poll + advanceTimeBy(100) + assertEquals("Line 1\n", uiState.value.subagentTranscript?.text) + assertFalse(uiState.value.subagentTranscript?.isLoading == true) + + // Advance time by polling interval + advanceTimeBy(1000) + assertEquals("Line 1\nLine 2\n", uiState.value.subagentTranscript?.text) + + delegate.closeSubagentTranscript() + } + + @Test + fun testInspectSubagentTranscript_stopsPollingWhenSubagentCompletes() = + testScope.runTest { + uiState.value = + uiState.value.copy( + subagentIndicators = + listOf( + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1", + status = "running", + ), + ), + ) + + coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns + SubagentTailResponse(subagentId = "sub-1", text = "Finished output", truncated = false) + + delegate.inspectSubagentTranscript("sub-1") + advanceTimeBy(100) + assertEquals("Finished output", uiState.value.subagentTranscript?.text) + + // Mark subagent completed + uiState.value = + uiState.value.copy( + subagentIndicators = + listOf( + SubagentIndicator( + type = "subagent.complete", + subagentId = "sub-1", + status = "completed", + ), + ), + ) + + advanceTimeBy(3000) + // Verify tailSubagent was called at most once or twice, not indefinitely + coVerify(atMost = 2) { mockRepository.tailSubagent("session-123", "sub-1", 16384) } + delegate.closeSubagentTranscript() + } + + @Test + fun testCloseSubagentTranscript_cancelsPollingAndClearsState() = + testScope.runTest { + uiState.value = + uiState.value.copy( + subagentIndicators = + listOf( + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1", + status = "running", + ), + ), + ) + + coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns + SubagentTailResponse(subagentId = "sub-1", text = "Output") + + delegate.inspectSubagentTranscript("sub-1") + advanceTimeBy(100) + assertNotNull(uiState.value.subagentTranscript) + + delegate.closeSubagentTranscript() + assertNull(uiState.value.inspectingSubagentId) + assertNull(uiState.value.subagentTranscript) + } + + @Test + fun testTailError_setsRetryableErrorState() = + testScope.runTest { + uiState.value = + uiState.value.copy( + subagentIndicators = + listOf( + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1", + status = "running", + ), + ), + ) + + coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } throws + RuntimeException("Gateway connection error") + + delegate.inspectSubagentTranscript("sub-1") + advanceTimeBy(100) + + val transcript = uiState.value.subagentTranscript + assertNotNull(transcript) + assertEquals("Gateway connection error", transcript?.error) + assertFalse(transcript?.isLoading == true) + + // Now recover on retry + coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns + SubagentTailResponse(subagentId = "sub-1", text = "Recovered logs") + + delegate.retryTranscript() + advanceTimeBy(100) + + val recovered = uiState.value.subagentTranscript + assertEquals("Recovered logs", recovered?.text) + assertNull(recovered?.error) + + delegate.closeSubagentTranscript() + } +}