This commit is contained in:
+121
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SubagentListItem> = emptyList(),
|
||||||
|
val delegations: List<JsonElement> = 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 ?: ""
|
||||||
|
}
|
||||||
@@ -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<SubagentListResponse>(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<String, Any>(
|
||||||
|
"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<SubagentTailResponse>(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
internal inline fun <reified T> 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<T>(), element)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
internal fun anyToJsonElement(value: Any?): JsonElement =
|
||||||
|
when (value) {
|
||||||
|
null -> {
|
||||||
|
JsonNull
|
||||||
|
}
|
||||||
|
|
||||||
|
is JsonElement -> {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
is Map<*, *> -> {
|
||||||
|
JsonObject(
|
||||||
|
(value as Map<String, Any?>).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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,14 @@ object WsMethods {
|
|||||||
/** Kill a single background process (scoped to the active session). */
|
/** Kill a single background process (scoped to the active session). */
|
||||||
const val PROCESS_KILL = "process.kill"
|
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) ─────────────────────────────
|
// ── Billing / subscription (issue #628) ─────────────────────────────
|
||||||
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
|
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
|
||||||
// `credits.view` was REMOVED upstream; these replace it. `usage.bars`
|
// `credits.view` was REMOVED upstream; these replace it. `usage.bars`
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ data class SubagentIndicator(
|
|||||||
val logs: List<SubagentLogLine> = emptyList(),
|
val logs: List<SubagentLogLine> = emptyList(),
|
||||||
val durationSeconds: Double? = null,
|
val durationSeconds: Double? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
|
val lastEventTimestamp: Long = 0L,
|
||||||
) {
|
) {
|
||||||
val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done"
|
val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done"
|
||||||
val isFailed: Boolean get() = status == "failed" || status == "interrupted"
|
val isFailed: Boolean get() = status == "failed" || status == "interrupted"
|
||||||
@@ -137,6 +138,20 @@ data class SubagentIndicator(
|
|||||||
val isRunning: Boolean get() = !isComplete && !isFailed && !isCancelled && !isQueued
|
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 {
|
enum class MessageRole {
|
||||||
USER,
|
USER,
|
||||||
ASSISTANT,
|
ASSISTANT,
|
||||||
|
|||||||
@@ -291,6 +291,11 @@ fun ChatScreen(
|
|||||||
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 showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(showSubagentInspectionSheet) {
|
||||||
|
if (showSubagentInspectionSheet) {
|
||||||
|
viewModel.hydrateSubagents()
|
||||||
|
}
|
||||||
|
}
|
||||||
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()
|
||||||
@@ -732,9 +737,16 @@ fun ChatScreen(
|
|||||||
SubagentInspectionSheet(
|
SubagentInspectionSheet(
|
||||||
indicators = state.subagentIndicators,
|
indicators = state.subagentIndicators,
|
||||||
todos = state.todos,
|
todos = state.todos,
|
||||||
|
inspectingSubagentId = state.inspectingSubagentId,
|
||||||
|
subagentTranscript = state.subagentTranscript,
|
||||||
|
onToggleTranscript = { subagentId -> viewModel.toggleSubagentTranscript(subagentId) },
|
||||||
|
onRetryTranscript = { viewModel.retrySubagentTranscript() },
|
||||||
onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) },
|
onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) },
|
||||||
onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) },
|
onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) },
|
||||||
onDismiss = { showSubagentInspectionSheet = false },
|
onDismiss = {
|
||||||
|
showSubagentInspectionSheet = false
|
||||||
|
viewModel.closeSubagentTranscript()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<ChatUiState>,
|
||||||
|
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<SubagentIndicator>,
|
||||||
|
listItems: List<SubagentListItem>,
|
||||||
|
requestTime: Long,
|
||||||
|
): List<SubagentIndicator> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,6 +164,10 @@ data class ChatUiState(
|
|||||||
val btwState: BtwUiState? = null,
|
val btwState: BtwUiState? = null,
|
||||||
/** 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(),
|
||||||
|
/** 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). */
|
/** Agent todo / plan items (issue #736). */
|
||||||
val todos: List<TodoItem> = emptyList(),
|
val todos: List<TodoItem> = emptyList(),
|
||||||
// Session resume recovery (desktop parity: bounded auto-retry + error UI)
|
// Session resume recovery (desktop parity: bounded auto-retry + error UI)
|
||||||
@@ -473,6 +477,14 @@ class ChatViewModel(
|
|||||||
trackRequest = { id, method -> trackRequest(id, method) },
|
trackRequest = { id, method -> trackRequest(id, method) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val subagentsDelegate =
|
||||||
|
ChatSubagentsDelegate(
|
||||||
|
uiState = _uiState,
|
||||||
|
scope = viewModelScope,
|
||||||
|
ioDispatcher = ioDispatcher,
|
||||||
|
runtimeSessionId = { runtimeSessionId ?: _uiState.value.currentSessionId },
|
||||||
|
)
|
||||||
|
|
||||||
private val streamingController =
|
private val streamingController =
|
||||||
ChatStreamingController(
|
ChatStreamingController(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -534,6 +546,7 @@ class ChatViewModel(
|
|||||||
status == ConnectionStatus.AUTH_EXPIRED
|
status == ConnectionStatus.AUTH_EXPIRED
|
||||||
) {
|
) {
|
||||||
_uiState.update { it.copy(isLoading = false) }
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
subagentsDelegate.closeSubagentTranscript()
|
||||||
// The runtime session id is only valid while the socket
|
// The runtime session id is only valid while the socket
|
||||||
// owns it — a dropped connection may mean the gateway
|
// owns it — a dropped connection may mean the gateway
|
||||||
// closed/pruned the session (or restarted, wiping the
|
// closed/pruned the session (or restarted, wiping the
|
||||||
@@ -1139,6 +1152,7 @@ class ChatViewModel(
|
|||||||
val generation = request?.generation ?: sessionGeneration
|
val generation = request?.generation ?: sessionGeneration
|
||||||
resumedGeneration = generation
|
resumedGeneration = generation
|
||||||
finishResumeWhenHydrated(generation)
|
finishResumeWhenHydrated(generation)
|
||||||
|
subagentsDelegate.hydrateSubagents(runtimeSessionId ?: sessionId)
|
||||||
// Reconnect replay: resume payload can carry `pending_approval`
|
// Reconnect replay: resume payload can carry `pending_approval`
|
||||||
// (server `_session_info_payload`); surface it, then ask for
|
// (server `_session_info_payload`); surface it, then ask for
|
||||||
// the full queue in case more are parked.
|
// 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) {
|
fun createNewSession(setLoading: Boolean = true) {
|
||||||
// A fresh create has no persisted row until the first prompt.
|
// A fresh create has no persisted row until the first prompt.
|
||||||
sessionHasServerPresence = false
|
sessionHasServerPresence = false
|
||||||
@@ -3451,6 +3481,7 @@ class ChatViewModel(
|
|||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
|
subagentsDelegate.closeSubagentTranscript()
|
||||||
// PERF-16: Don't disconnect the global HermesWsClient singleton when
|
// PERF-16: Don't disconnect the global HermesWsClient singleton when
|
||||||
// leaving the Chat screen — it's used by background notification reply.
|
// leaving the Chat screen — it's used by background notification reply.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -946,6 +946,7 @@ object ChatWsEventReducer {
|
|||||||
logs = trimmedLogs,
|
logs = trimmedLogs,
|
||||||
durationSeconds = durationSeconds ?: (if (idx >= 0) indicators[idx].durationSeconds else null),
|
durationSeconds = durationSeconds ?: (if (idx >= 0) indicators[idx].durationSeconds else null),
|
||||||
model = model ?: (if (idx >= 0) indicators[idx].model else null),
|
model = model ?: (if (idx >= 0) indicators[idx].model else null),
|
||||||
|
lastEventTimestamp = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
|
|||||||
+235
-1
@@ -4,20 +4,25 @@ import androidx.compose.animation.AnimatedVisibility
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.AltRoute
|
import androidx.compose.material.icons.automirrored.filled.AltRoute
|
||||||
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
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.HourglassEmpty
|
||||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
@@ -45,6 +53,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -63,6 +72,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||||
|
import com.m57.hermescontrol.ui.chat.SubagentTranscriptUiState
|
||||||
import com.m57.hermescontrol.ui.chat.TodoItem
|
import com.m57.hermescontrol.ui.chat.TodoItem
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,6 +83,10 @@ import com.m57.hermescontrol.ui.chat.TodoItem
|
|||||||
fun SubagentInspectionSheet(
|
fun SubagentInspectionSheet(
|
||||||
indicators: List<SubagentIndicator> = emptyList(),
|
indicators: List<SubagentIndicator> = emptyList(),
|
||||||
todos: List<TodoItem> = emptyList(),
|
todos: List<TodoItem> = emptyList(),
|
||||||
|
inspectingSubagentId: String? = null,
|
||||||
|
subagentTranscript: SubagentTranscriptUiState? = null,
|
||||||
|
onToggleTranscript: ((String) -> Unit)? = null,
|
||||||
|
onRetryTranscript: (() -> Unit)? = null,
|
||||||
onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null,
|
onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null,
|
||||||
onStopSubagent: ((SubagentIndicator) -> Unit)? = null,
|
onStopSubagent: ((SubagentIndicator) -> Unit)? = null,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@@ -197,7 +211,7 @@ fun SubagentInspectionSheet(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "SUBAGENTS",
|
text = stringResource(R.string.subagent_subagents_header).uppercase(),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
@@ -211,8 +225,14 @@ fun SubagentInspectionSheet(
|
|||||||
"subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}"
|
"subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}"
|
||||||
},
|
},
|
||||||
) { _, indicator ->
|
) { _, indicator ->
|
||||||
|
val isInspectingThis =
|
||||||
|
indicator.subagentId != null && indicator.subagentId == inspectingSubagentId
|
||||||
InspectionItemCard(
|
InspectionItemCard(
|
||||||
indicator = indicator,
|
indicator = indicator,
|
||||||
|
isInspectingTranscript = isInspectingThis,
|
||||||
|
transcriptState = if (isInspectingThis) subagentTranscript else null,
|
||||||
|
onToggleTranscript = onToggleTranscript,
|
||||||
|
onRetryTranscript = onRetryTranscript,
|
||||||
onSteer =
|
onSteer =
|
||||||
if (onSteerSubagent != null) {
|
if (onSteerSubagent != null) {
|
||||||
{ message -> onSteerSubagent(indicator, message) }
|
{ message -> onSteerSubagent(indicator, message) }
|
||||||
@@ -448,6 +468,10 @@ private fun TodoInspectionCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun InspectionItemCard(
|
private fun InspectionItemCard(
|
||||||
indicator: SubagentIndicator,
|
indicator: SubagentIndicator,
|
||||||
|
isInspectingTranscript: Boolean = false,
|
||||||
|
transcriptState: SubagentTranscriptUiState? = null,
|
||||||
|
onToggleTranscript: ((String) -> Unit)? = null,
|
||||||
|
onRetryTranscript: (() -> Unit)? = null,
|
||||||
onSteer: ((String) -> Unit)? = null,
|
onSteer: ((String) -> Unit)? = null,
|
||||||
onStop: (() -> 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)
|
// Interactive Controls for active subagent (issue #1030)
|
||||||
if (indicator.isRunning || indicator.isSteered) {
|
if (indicator.isRunning || indicator.isSteered) {
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1275,6 +1275,14 @@
|
|||||||
<string name="subagent_status_cancelled">Cancelled</string>
|
<string name="subagent_status_cancelled">Cancelled</string>
|
||||||
<string name="subagent_status_failed">Failed</string>
|
<string name="subagent_status_failed">Failed</string>
|
||||||
<string name="subagent_duration">%1$.1fs</string>
|
<string name="subagent_duration">%1$.1fs</string>
|
||||||
|
<string name="subagent_subagents_header">Subagents</string>
|
||||||
|
<string name="subagent_inspect_transcript">Inspect Transcript</string>
|
||||||
|
<string name="subagent_hide_transcript">Hide Transcript</string>
|
||||||
|
<string name="subagent_transcript_loading">Loading live transcript…</string>
|
||||||
|
<string name="subagent_transcript_empty">No transcript output available.</string>
|
||||||
|
<string name="subagent_transcript_error">Unable to load transcript</string>
|
||||||
|
<string name="subagent_transcript_retry">Retry</string>
|
||||||
|
<string name="subagent_transcript_truncated">Rolling 16KB tail (older output truncated)</string>
|
||||||
<string name="system_auth_required">Auth required</string>
|
<string name="system_auth_required">Auth required</string>
|
||||||
<string name="system_checkpoint_prune">Checkpoint prune</string>
|
<string name="system_checkpoint_prune">Checkpoint prune</string>
|
||||||
<string name="system_config_migrate">Config migrate</string>
|
<string name="system_config_migrate">Config migrate</string>
|
||||||
|
|||||||
@@ -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<Any>(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = SubagentRepository.decode<SubagentListResponse>(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<String, Any>(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = SubagentRepository.decode<SubagentListResponse>(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<SubagentTailResponse>(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<SubagentTailResponse>(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<SubagentListItem>(null)
|
||||||
|
assertNull(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SubagentRepository>()
|
||||||
|
|
||||||
|
private lateinit var delegate: ChatSubagentsDelegate
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
mockkStatic(Log::class)
|
||||||
|
every { Log.w(any(), any<String>()) } 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user