feat(sessions): show live status for active sessions in History (#1101) (#1104)

This commit is contained in:
M57 authored and GitHub committed 2026-09-13 14:45:33 +03:00
1 parent 52e0104cd2
commit 7e8af8db01
16 files changed
+1485 -12

No files matched your search

@@ -0,0 +1,168 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
import com.m57.hermescontrol.data.model.SessionLiveStatus
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionLiveStatusSourceTest {
@Test
fun `decodeSnapshot decodes working and waiting statuses by stored id`() {
val payload =
mapOf(
"sessions" to
listOf(
mapOf(
"id" to "runtime-1",
"session_key" to "stored-1",
"status" to "working",
),
mapOf(
"id" to "runtime-2",
"session_key" to "stored-2",
"status" to "waiting",
),
),
)
val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload)
assertNotNull(snapshot)
assertEquals(SessionLiveStatus.WORKING, snapshot!!.statusByStoredId["stored-1"])
assertEquals(SessionLiveStatus.WAITING, snapshot.statusByStoredId["stored-2"])
assertEquals("stored-1", snapshot.storedIdByRuntimeId["runtime-1"])
assertEquals("stored-2", snapshot.storedIdByRuntimeId["runtime-2"])
}
@Test
fun `decodeSnapshot ignores idle and starting for status but keeps runtime mapping`() {
val payload =
mapOf(
"sessions" to
listOf(
mapOf(
"id" to "runtime-idle",
"session_key" to "stored-idle",
"status" to "idle",
),
mapOf(
"id" to "runtime-start",
"session_key" to "stored-start",
"status" to "starting",
),
),
)
val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload)
assertNotNull(snapshot)
assertTrue(snapshot!!.statusByStoredId.isEmpty())
assertEquals("stored-idle", snapshot.storedIdByRuntimeId["runtime-idle"])
assertEquals("stored-start", snapshot.storedIdByRuntimeId["runtime-start"])
}
@Test
fun `decodeSnapshot skips items with blank id or session_key`() {
val payload =
mapOf(
"sessions" to
listOf(
mapOf(
"id" to "",
"session_key" to "stored-1",
"status" to "working",
),
mapOf(
"id" to "runtime-2",
"session_key" to " ",
"status" to "working",
),
mapOf(
"id" to "runtime-3",
"session_key" to "stored-3",
"status" to "working",
),
),
)
val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload)
assertNotNull(snapshot)
assertEquals(1, snapshot!!.statusByStoredId.size)
assertEquals(SessionLiveStatus.WORKING, snapshot.statusByStoredId["stored-3"])
assertEquals(1, snapshot.storedIdByRuntimeId.size)
assertEquals("stored-3", snapshot.storedIdByRuntimeId["runtime-3"])
}
@Test
fun `decodeSnapshot returns authoritative empty snapshot on empty sessions list`() {
val payload = mapOf("sessions" to emptyList<Map<String, Any>>())
val snapshot = SessionLiveStatusDecoder.decodeSnapshot(payload)
assertNotNull(snapshot)
assertTrue(snapshot!!.statusByStoredId.isEmpty())
assertTrue(snapshot.storedIdByRuntimeId.isEmpty())
}
@Test
fun `decodeSnapshot returns null for malformed or missing sessions field`() {
assertNull(SessionLiveStatusDecoder.decodeSnapshot(null))
assertNull(SessionLiveStatusDecoder.decodeSnapshot(mapOf("other" to "data")))
assertNull(SessionLiveStatusDecoder.decodeSnapshot("invalid-json-string"))
}
@Test
fun `HermesSessionLiveStatusSource fetches and decodes snapshot via RPC`() =
runBlocking {
var capturedMethod: String? = null
var capturedParams: Map<String, Any>? = null
val source =
HermesSessionLiveStatusSource(
rpcRequest = { method, params ->
capturedMethod = method
capturedParams = params
mapOf(
"sessions" to
listOf(
mapOf(
"id" to "rt-1",
"session_key" to "st-1",
"status" to "working",
),
),
)
},
eventsProvider = { MutableSharedFlow() },
connectionStatusProvider = { MutableStateFlow(ConnectionStatus.CONNECTED) },
)
val snapshot = source.fetchActiveSessionsSnapshot()
assertEquals(WsMethods.SESSION_ACTIVE_LIST, capturedMethod)
assertTrue(capturedParams!!.isEmpty())
assertNotNull(snapshot)
assertEquals(SessionLiveStatus.WORKING, snapshot!!.statusByStoredId["st-1"])
}
@Test
fun `HermesSessionLiveStatusSource returns null when RPC throws`() =
runBlocking {
val source =
HermesSessionLiveStatusSource(
rpcRequest = { _, _ -> throw RuntimeException("RPC error") },
eventsProvider = { MutableSharedFlow() },
connectionStatusProvider = { MutableStateFlow(ConnectionStatus.CONNECTED) },
)
val snapshot = source.fetchActiveSessionsSnapshot()
assertNull(snapshot)
}
}
@@ -0,0 +1,239 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
import com.m57.hermescontrol.data.model.SessionLiveStatus
import com.m57.hermescontrol.data.ws.WsEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionLiveStatusReducerTest {
@Test
fun `snapshot replacement seeds WORKING and WAITING by stored ID`() {
val snapshot =
LiveSessionSnapshot(
statusByStoredId =
mapOf(
"stored-1" to SessionLiveStatus.WORKING,
"stored-2" to SessionLiveStatus.WAITING,
),
storedIdByRuntimeId =
mapOf(
"rt-1" to "stored-1",
"rt-2" to "stored-2",
),
)
val state = SessionLiveStatusReducer.applySnapshot(SessionLiveTrackingState(), snapshot)
assertEquals(SessionLiveStatus.WORKING, state.liveStatuses["stored-1"])
assertEquals(SessionLiveStatus.WAITING, state.liveStatuses["stored-2"])
assertEquals("stored-1", state.storedIdByRuntimeId["rt-1"])
assertEquals("stored-2", state.storedIdByRuntimeId["rt-2"])
}
@Test
fun `later successful empty snapshot removes previously live rows`() {
val initial =
SessionLiveTrackingState(
liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING),
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
)
val state =
SessionLiveStatusReducer.applySnapshot(
initial,
LiveSessionSnapshot(emptyMap(), emptyMap()),
)
assertTrue(state.liveStatuses.isEmpty())
assertTrue(state.storedIdByRuntimeId.isEmpty())
}
@Test
fun `runtime ID reuse moves activity from old stored ID to new stored ID`() {
val initial =
SessionLiveTrackingState(
liveStatuses = mapOf("stored-old" to SessionLiveStatus.WORKING),
storedIdByRuntimeId = mapOf("rt-1" to "stored-old"),
)
val event =
WsEvent.SessionInfo(
data =
mapOf(
"session_id" to "rt-1",
"stored_session_id" to "stored-new",
"running" to true,
),
)
val state = SessionLiveStatusReducer.applyWsEvent(initial, event)
assertNull(state.liveStatuses["stored-old"])
assertEquals(SessionLiveStatus.WORKING, state.liveStatuses["stored-new"])
assertEquals("stored-new", state.storedIdByRuntimeId["rt-1"])
}
@Test
fun `session info with stored_session_id and running=true marks WORKING`() {
val initial = SessionLiveTrackingState()
val event =
WsEvent.SessionInfo(
data =
mapOf(
"session_id" to "rt-1",
"stored_session_id" to "stored-1",
"running" to true,
),
)
val state = SessionLiveStatusReducer.applyWsEvent(initial, event)
assertEquals(SessionLiveStatus.WORKING, state.liveStatuses["stored-1"])
assertEquals("stored-1", state.storedIdByRuntimeId["rt-1"])
}
@Test
fun `session info with running=false clears that stored row`() {
val initial =
SessionLiveTrackingState(
liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING),
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
)
val event =
WsEvent.SessionInfo(
data =
mapOf(
"session_id" to "rt-1",
"stored_session_id" to "stored-1",
"running" to false,
),
)
val state = SessionLiveStatusReducer.applyWsEvent(initial, event)
assertNull(state.liveStatuses["stored-1"])
assertEquals("stored-1", state.storedIdByRuntimeId["rt-1"])
}
@Test
fun `message start marks WORKING only when runtime-to-stored identity is known`() {
val initial =
SessionLiveTrackingState(
liveStatuses = emptyMap(),
storedIdByRuntimeId = mapOf("rt-known" to "stored-known"),
)
// Unknown runtime id -> no mutation
val unknownEvent = WsEvent.MessageStart(sessionId = "rt-unknown")
val state1 = SessionLiveStatusReducer.applyWsEvent(initial, unknownEvent)
assertTrue(state1.liveStatuses.isEmpty())
// Known runtime id -> marks WORKING
val knownEvent = WsEvent.MessageStart(sessionId = "rt-known")
val state2 = SessionLiveStatusReducer.applyWsEvent(state1, knownEvent)
assertEquals(SessionLiveStatus.WORKING, state2.liveStatuses["stored-known"])
}
@Test
fun `message complete and message done clear only matching runtime`() {
val initial =
SessionLiveTrackingState(
liveStatuses =
mapOf(
"stored-1" to SessionLiveStatus.WORKING,
"stored-2" to SessionLiveStatus.WORKING,
),
storedIdByRuntimeId =
mapOf(
"rt-1" to "stored-1",
"rt-2" to "stored-2",
),
)
val completeEvent = WsEvent.MessageComplete(text = "done", sessionId = "rt-1")
val state1 = SessionLiveStatusReducer.applyWsEvent(initial, completeEvent)
assertNull(state1.liveStatuses["stored-1"])
assertEquals(SessionLiveStatus.WORKING, state1.liveStatuses["stored-2"])
val doneEvent = WsEvent.MessageDone(sessionId = "rt-2")
val state2 = SessionLiveStatusReducer.applyWsEvent(state1, doneEvent)
assertNull(state2.liveStatuses["stored-2"])
assertTrue(state2.liveStatuses.isEmpty())
}
@Test
fun `approval request and clarify request mark WAITING for known runtime`() {
val initial =
SessionLiveTrackingState(
liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING),
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
)
val approvalEvent =
WsEvent.ApprovalRequest(
command = "rm -rf",
description = "delete",
patternKeys = null,
sessionId = "rt-1",
requestId = "req-1",
choices = listOf("allow", "deny"),
allowPermanent = false,
smartDenied = false,
)
val state = SessionLiveStatusReducer.applyWsEvent(initial, approvalEvent)
assertEquals(SessionLiveStatus.WAITING, state.liveStatuses["stored-1"])
val clarifyEvent =
WsEvent.ClarifyRequest(
text = "which option?",
options = listOf("a", "b"),
sessionId = "rt-1",
)
val state2 = SessionLiveStatusReducer.applyWsEvent(initial, clarifyEvent)
assertEquals(SessionLiveStatus.WAITING, state2.liveStatuses["stored-1"])
}
@Test
fun `unknown or missing IDs never mutate another session`() {
val initial =
SessionLiveTrackingState(
liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING),
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
)
val state1 = SessionLiveStatusReducer.applyWsEvent(initial, WsEvent.MessageStart(sessionId = null))
assertEquals(initial, state1)
val state2 = SessionLiveStatusReducer.applyWsEvent(initial, WsEvent.MessageDone(sessionId = "other"))
assertEquals(initial, state2)
val state3 =
SessionLiveStatusReducer.applyWsEvent(
initial,
WsEvent.SessionInfo(data = null),
)
assertEquals(initial, state3)
}
@Test
fun `clear resets state to empty`() {
val initial =
SessionLiveTrackingState(
liveStatuses = mapOf("stored-1" to SessionLiveStatus.WORKING),
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
)
val cleared = SessionLiveStatusReducer.clear()
assertTrue(cleared.liveStatuses.isEmpty())
assertTrue(cleared.storedIdByRuntimeId.isEmpty())
}
}
@@ -1,19 +1,31 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.SessionLiveStatus
import com.m57.hermescontrol.data.model.SessionSearchResponse
import com.m57.hermescontrol.data.model.SessionSearchResult
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.ws.ChangeEventHub
import com.m57.hermescontrol.data.ws.ChangeEvents
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.SessionLiveStatusSource
import com.m57.hermescontrol.data.ws.WsEvent
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
@@ -24,6 +36,7 @@ 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
@@ -34,8 +47,25 @@ class SessionsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockApi = mockk<HermesApiService>(relaxed = true)
private fun createViewModel(): SessionsViewModel {
val vm = SessionsViewModel()
private class FakeSessionLiveStatusSource : SessionLiveStatusSource {
val eventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 16)
val connectionStatusFlow = MutableStateFlow(ConnectionStatus.CONNECTED)
var snapshotToReturn: LiveSessionSnapshot? = null
var fetchCallCount = 0
var snapshotDeferred: CompletableDeferred<LiveSessionSnapshot?>? = null
override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? {
fetchCallCount++
val deferred = snapshotDeferred
return if (deferred != null) deferred.await() else snapshotToReturn
}
override val events: Flow<WsEvent> = eventsFlow
override val connectionStatus: StateFlow<ConnectionStatus> = connectionStatusFlow
}
private fun createViewModel(source: SessionLiveStatusSource = FakeSessionLiveStatusSource()): SessionsViewModel {
val vm = SessionsViewModel(liveStatusSource = source)
testDispatcher.scheduler.advanceUntilIdle()
return vm
}
@@ -548,4 +578,219 @@ class SessionsViewModelTest {
vm.togglePinnedExpanded()
assertTrue(vm.uiState.value.pinnedExpanded)
}
// ── Live Session Status Tracking (issue #1101) ───────────────────────
@Test
fun `initial snapshot hydration updates liveStatuses`() {
val source = FakeSessionLiveStatusSource()
source.snapshotToReturn =
LiveSessionSnapshot(
statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING),
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
)
val vm = createViewModel(source)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"])
assertEquals(1, source.fetchCallCount)
vm.stopLiveStatusTracking()
}
@Test
fun `successful absence reaping clears statuses not in snapshot`() {
val source = FakeSessionLiveStatusSource()
source.snapshotToReturn =
LiveSessionSnapshot(
statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING),
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
)
val vm = createViewModel(source)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"])
// Second snapshot returns empty
source.snapshotToReturn = LiveSessionSnapshot(emptyMap(), emptyMap())
vm.refreshLiveStatuses()
testDispatcher.scheduler.runCurrent()
assertTrue(
vm.uiState.value.liveStatuses
.isEmpty(),
)
assertEquals(2, source.fetchCallCount)
vm.stopLiveStatusTracking()
}
@Test
fun `working to idle event transition via SessionInfo updates liveStatuses`() {
val source = FakeSessionLiveStatusSource()
source.snapshotToReturn =
LiveSessionSnapshot(
statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING),
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
)
val vm = createViewModel(source)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"])
// Send running = false
source.eventsFlow.tryEmit(
WsEvent.SessionInfo(
data =
mapOf(
"session_id" to "rt-1",
"stored_session_id" to "stored-1",
"running" to false,
),
),
)
testDispatcher.scheduler.runCurrent()
assertNull(vm.uiState.value.liveStatuses["stored-1"])
vm.stopLiveStatusTracking()
}
@Test
fun `disconnect clears immediately and reconnect rehydrates`() {
val source = FakeSessionLiveStatusSource()
source.snapshotToReturn =
LiveSessionSnapshot(
statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING),
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
)
val vm = createViewModel(source)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
assertEquals(SessionLiveStatus.WORKING, vm.uiState.value.liveStatuses["stored-1"])
// Disconnect
source.connectionStatusFlow.value = ConnectionStatus.DISCONNECTED
testDispatcher.scheduler.runCurrent()
assertTrue(
vm.uiState.value.liveStatuses
.isEmpty(),
)
// Reconnect
source.snapshotToReturn =
LiveSessionSnapshot(
statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WAITING),
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
)
source.connectionStatusFlow.value = ConnectionStatus.CONNECTED
testDispatcher.scheduler.runCurrent()
assertEquals(SessionLiveStatus.WAITING, vm.uiState.value.liveStatuses["stored-1"])
vm.stopLiveStatusTracking()
}
@Test
fun `late pre-disconnect response is ignored`() {
val source = FakeSessionLiveStatusSource()
val deferred = CompletableDeferred<LiveSessionSnapshot?>()
source.snapshotDeferred = deferred
val vm = createViewModel(source)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
// Disconnect before deferred completes
source.connectionStatusFlow.value = ConnectionStatus.DISCONNECTED
testDispatcher.scheduler.runCurrent()
// Complete the late response
deferred.complete(
LiveSessionSnapshot(
statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING),
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
),
)
testDispatcher.scheduler.runCurrent()
// Must remain empty because the response was from the previous generation
assertTrue(
vm.uiState.value.liveStatuses
.isEmpty(),
)
vm.stopLiveStatusTracking()
}
@Test
fun `repeated start is idempotent`() {
val source = FakeSessionLiveStatusSource()
source.snapshotToReturn = LiveSessionSnapshot()
val vm = createViewModel(source)
vm.startLiveStatusTracking()
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
assertEquals(1, source.fetchCallCount)
vm.stopLiveStatusTracking()
}
@Test
fun `stop cancels ticker and clears state`() {
val source = FakeSessionLiveStatusSource()
source.snapshotToReturn =
LiveSessionSnapshot(
statusByStoredId = mapOf("stored-1" to SessionLiveStatus.WORKING),
storedIdByRuntimeId = mapOf("rt-1" to "stored-1"),
)
val vm = createViewModel(source)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
assertEquals(1, vm.uiState.value.liveStatuses.size)
vm.stopLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
assertTrue(
vm.uiState.value.liveStatuses
.isEmpty(),
)
// Advancing time past the 30s poll interval should NOT trigger another fetch
testDispatcher.scheduler.advanceTimeBy(60_000)
testDispatcher.scheduler.runCurrent()
assertEquals(1, source.fetchCallCount)
}
@Test
fun `change bursts coalesce into at most one trailing request`() {
val source = FakeSessionLiveStatusSource()
val deferred1 = CompletableDeferred<LiveSessionSnapshot?>()
source.snapshotDeferred = deferred1
val vm = createViewModel(source)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
assertEquals(1, source.fetchCallCount)
// Multiple change events while request 1 is in flight
source.snapshotDeferred = CompletableDeferred()
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS, null))
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS, null))
testDispatcher.scheduler.runCurrent()
// Call count should still be 1 (in-flight)
assertEquals(1, source.fetchCallCount)
// Complete request 1
deferred1.complete(LiveSessionSnapshot())
testDispatcher.scheduler.runCurrent()
// Request 2 was coalesced and launched as the trailing request
assertEquals(2, source.fetchCallCount)
vm.stopLiveStatusTracking()
}
}