feat(subagents): live active subagents roster and transcript tail (#1089) (#1098)

This commit is contained in:
M57 authored and GitHub committed 2026-09-13 11:06:34 +03:00
1 parent 69cd34d289
commit 29bc367aec
13 files changed
+1452 -2

No files matched your search

@@ -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()
}
}