feat(sessions): separate conversation and automation history

This commit is contained in:
AlexDev_ committed 2026-09-05 19:55:57 +02:00
1 parent fd485a6999
commit acc9c7ce9a
9 files changed
+628 -166

No files matched your search

@@ -629,7 +629,7 @@ class E2eIntegrationTest {
runTest {
val session = SessionInfo("session-123", "Session 1", "2026-06-15T15:10:00Z", 5, "active")
coEvery {
mockApiService.getSessions(any(), any(), any())
mockApiService.getSessions(any(), any(), any(), null, "cron")
} returns Response.success(SessionListResponse(listOf(session)))
val viewModel = SessionsViewModel()
@@ -665,7 +665,7 @@ class E2eIntegrationTest {
@Test
fun testSessionsLoad_failure() =
runTest {
coEvery { mockApiService.getSessions(any(), any(), any()) } returns createErrorResponse(500)
coEvery { mockApiService.getSessions(any(), any(), any(), null, "cron") } returns createErrorResponse(500)
val viewModel = SessionsViewModel()
viewModel.loadSessions()
@@ -205,6 +205,29 @@ class HermesApiServiceMockWebServerTest {
assertEquals(0, body.total)
}
@Test
fun historyFiltersAreSentOnListAndSearch() =
runBlocking {
mockServer.enqueue(MockResponse().setBody("""{"sessions":[],"total":0}"""))
api.getSessions(source = "cron", offset = 50)
val automationRequest = mockServer.takeRequest().requestUrl!!
assertEquals("cron", automationRequest.queryParameter("source"))
assertEquals("50", automationRequest.queryParameter("offset"))
assertEquals(null, automationRequest.queryParameter("exclude_sources"))
mockServer.enqueue(MockResponse().setBody("""{"sessions":[],"total":0}"""))
api.getSessions(excludeSources = "cron")
assertEquals("cron", mockServer.takeRequest().requestUrl!!.queryParameter("exclude_sources"))
mockServer.enqueue(MockResponse().setBody("""{"results":[]}"""))
api.searchSessions(q = "test", source = "cron")
assertEquals("cron", mockServer.takeRequest().requestUrl!!.queryParameter("source"))
mockServer.enqueue(MockResponse().setBody("""{"results":[]}"""))
api.searchSessions(q = "test", excludeSources = "cron")
assertEquals("cron", mockServer.takeRequest().requestUrl!!.queryParameter("exclude_sources"))
}
@Test
fun getSessionMessages_parsesResponse() =
runBlocking {
@@ -0,0 +1,56 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.SessionInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class HistorySessionGroupsTest {
@Test
fun groupsDurableJobIdsNotTitles() {
val groups =
automationGroups(
listOf(
SessionInfo("cron_job_one_20260905_100000", title = "Same", source = "cron"),
SessionInfo("cron_job_two_20260905_100000", title = "Same", source = "cron"),
SessionInfo("cron_job_one_20260905_110000", title = "Renamed", source = "cron"),
),
)
assertEquals(listOf("job_one", "job_two"), groups.map { it.jobId })
assertEquals(2, groups.first().sessions.size)
assertEquals(
"cron_job_one_20260905_110000",
groups
.first()
.sessions
.last()
.id,
)
}
@Test
fun unknownRunIdsRemainIndependentlyAccessible() {
val groups =
automationGroups(
listOf(
SessionInfo("legacy-a", title = "Same", source = "cron"),
SessionInfo("legacy-b", title = "Same", source = "cron"),
),
)
assertEquals(2, groups.size)
assertNull(groups.first().jobId)
assertEquals(listOf("legacy-a", "legacy-b"), groups.flatMap { it.sessions }.map { it.id })
}
@Test
fun repeatedPagePinsDoNotDuplicateRuns() {
val run = SessionInfo("cron_job_20260905_100000", source = "cron", pinned = true)
assertEquals(1, automationGroups(listOf(run, run)).single().sessions.size)
}
@Test
fun cronLikeConversationIdDoesNotInventAJob() {
val groups = automationGroups(listOf(SessionInfo("cron_job_20260905_100000", source = "cli")))
assertNull(groups.single().jobId)
}
}
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionListResponse
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 io.mockk.coEvery
@@ -98,6 +100,127 @@ class SessionsViewModelTest {
)
}
@Test
fun `sections request conversations excluding cron and automations from cron`() {
val vm = createViewModel()
coEvery { mockApi.getSessions(50, 0, any(), null, "cron") } returns
Response.success(SessionListResponse(listOf(SessionInfo("conversation"))))
coEvery { mockApi.getSessions(50, 0, any(), "cron", null) } returns
Response.success(SessionListResponse(listOf(SessionInfo("automation", source = "cron"))))
vm.loadSessions()
testDispatcher.scheduler.advanceUntilIdle()
vm.toggleSelecting()
vm.toggleSessionSelection("conversation")
vm.selectSection(HistorySection.AUTOMATIONS)
assertFalse(vm.uiState.value.isSelecting)
assertTrue(
vm.uiState.value.selectedIds
.isEmpty(),
)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
listOf("automation"),
vm.uiState.value.sessions
.map { it.id },
)
coVerify(exactly = 1) { mockApi.getSessions(50, 0, any(), null, "cron") }
coVerify(exactly = 1) { mockApi.getSessions(50, 0, any(), "cron", null) }
}
@Test
fun `section change clears old results and repeats query in the new scope`() {
val vm = createViewModel()
coEvery { mockApi.searchSessions("deploy", null, null, "cron") } returns
Response.success(
SessionSearchResponse(listOf(SessionSearchResult(session_id = "conversation-hit"))),
)
coEvery { mockApi.searchSessions("deploy", null, "cron", null) } returns
Response.success(
SessionSearchResponse(listOf(SessionSearchResult(session_id = "automation-hit", source = "cron"))),
)
vm.setSearchQuery("deploy")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
"conversation-hit",
vm.uiState.value.searchResults
.single()
.session_id,
)
vm.selectAll(setOf("conversation-hit"))
vm.selectSection(HistorySection.AUTOMATIONS)
assertEquals("deploy", vm.uiState.value.searchQuery)
assertTrue(
vm.uiState.value.searchResults
.isEmpty(),
)
assertTrue(
vm.uiState.value.selectedIds
.isEmpty(),
)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
"automation-hit",
vm.uiState.value.searchResults
.single()
.session_id,
)
coVerify(exactly = 1) { mockApi.searchSessions("deploy", null, null, "cron") }
coVerify(exactly = 1) { mockApi.searchSessions("deploy", null, "cron", null) }
}
@Test
fun `automation pagination keeps the source filter and combines runs by job`() {
val vm = createViewModel()
val firstPage = (0 until 50).map { SessionInfo("cron_job-a_20260905_1800%02d".format(it), source = "cron") }
val lastRun = SessionInfo("cron_job-a_20260905_190000", source = "cron")
coEvery { mockApi.getSessions(50, 0, any(), "cron", null) } returns
Response.success(SessionListResponse(firstPage, total = 51, limit = 50))
coEvery { mockApi.getSessions(50, 50, any(), "cron", null) } returns
Response.success(SessionListResponse(listOf(lastRun), total = 51, limit = 50, offset = 50))
vm.selectSection(HistorySection.AUTOMATIONS)
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.hasMore)
vm.loadMore()
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(vm.uiState.value.hasMore)
assertEquals(51, vm.uiState.value.total)
assertEquals(firstPage + lastRun, automationGroups(vm.uiState.value.sessions).single().sessions)
coVerify(exactly = 1) { mockApi.getSessions(50, 50, any(), "cron", null) }
}
@Test
fun `late load from a previous section cannot replace the current rows`() {
val vm = createViewModel()
val oldResponse = kotlinx.coroutines.CompletableDeferred<Response<SessionListResponse>>()
coEvery { mockApi.getSessions(50, 0, any(), null, "cron") } coAnswers {
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { oldResponse.await() }
}
coEvery { mockApi.getSessions(50, 0, any(), "cron", null) } returns
Response.success(SessionListResponse(listOf(SessionInfo("automation", source = "cron"))))
vm.loadSessions()
testDispatcher.scheduler.runCurrent()
vm.selectSection(HistorySection.AUTOMATIONS)
testDispatcher.scheduler.runCurrent()
oldResponse.complete(Response.success(SessionListResponse(listOf(SessionInfo("old-conversation")))))
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(HistorySection.AUTOMATIONS, vm.uiState.value.section)
assertEquals(
listOf("automation"),
vm.uiState.value.sessions
.map { it.id },
)
}
// ── Pagination (fluid load-more) ──────────────────────────────────────
@Test
@@ -106,7 +229,7 @@ class SessionsViewModelTest {
// Server reports total = 10 (e.g. cross-profile count), but returns only 2 sessions (< PAGE_SIZE 50).
// hasMore must be false and total must be capped at 2 to prevent infinite auto-load loops.
coEvery { mockApi.getSessions(any(), any(), any()) } returns
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
Response.success(
SessionListResponse(
sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")),
@@ -132,7 +255,15 @@ class SessionsViewModelTest {
val page1Sessions = (1..50).map { SessionInfo("s-$it") }
// Page 1: 50 sessions of 51 total — hasMore stays true.
coEvery { mockApi.getSessions(limit = 50, offset = 0, order = any()) } returns
coEvery {
mockApi.getSessions(
limit = 50,
offset = 0,
order = any(),
source = null,
excludeSources = "cron",
)
} returns
Response.success(
SessionListResponse(
sessions = page1Sessions,
@@ -147,7 +278,15 @@ class SessionsViewModelTest {
// Page 2 overlaps page 1 (offset churn: a new session landed on top
// between loads) — the duplicate id must not double-append.
coEvery { mockApi.getSessions(limit = 50, offset = 50, order = any()) } returns
coEvery {
mockApi.getSessions(
limit = 50,
offset = 50,
order = any(),
source = null,
excludeSources = "cron",
)
} returns
Response.success(
SessionListResponse(
sessions = listOf(SessionInfo("s-51"), SessionInfo("s-50")),
@@ -168,7 +307,15 @@ class SessionsViewModelTest {
val vm = createViewModel()
val page1Sessions = (1..50).map { SessionInfo("s-$it") }
coEvery { mockApi.getSessions(limit = 50, offset = 0, order = any()) } returns
coEvery {
mockApi.getSessions(
limit = 50,
offset = 0,
order = any(),
source = null,
excludeSources = "cron",
)
} returns
Response.success(
SessionListResponse(
sessions = page1Sessions,
@@ -185,7 +332,7 @@ class SessionsViewModelTest {
testDispatcher.scheduler.advanceUntilIdle()
// loadSessions (1) + exactly one loadMore (1) = 2 API hits total.
coVerify(exactly = 2) { mockApi.getSessions(any(), any(), any()) }
coVerify(exactly = 2) { mockApi.getSessions(any(), any(), any(), null, "cron") }
assertFalse(vm.uiState.value.isLoadingMore)
}
@@ -196,7 +343,7 @@ class SessionsViewModelTest {
val vm = createViewModel()
// Backend returns recency order with the pinned flag set; the client
// must lift pins above the rest while keeping the rest's order.
coEvery { mockApi.getSessions(any(), any(), any()) } returns
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
Response.success(
SessionListResponse(
sessions =
@@ -220,7 +367,7 @@ class SessionsViewModelTest {
@Test
fun `togglePin moves the session to the top and sets the flag`() {
val vm = createViewModel()
coEvery { mockApi.getSessions(any(), any(), any()) } returns
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
Response.success(
SessionListResponse(
sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")),
@@ -251,7 +398,7 @@ class SessionsViewModelTest {
@Test
fun `togglePin failure keeps the order and surfaces a toast`() {
val vm = createViewModel()
coEvery { mockApi.getSessions(any(), any(), any()) } returns
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
Response.success(
SessionListResponse(
sessions = listOf(SessionInfo("s-1", pinned = true), SessionInfo("s-2")),
@@ -285,7 +432,7 @@ class SessionsViewModelTest {
@Test
fun `displaySessions filters out hidden sessions by default and shows them on toggleShowHidden`() {
val vm = createViewModel()
coEvery { mockApi.getSessions(any(), any(), any()) } returns
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
Response.success(
SessionListResponse(
sessions =
@@ -328,7 +475,7 @@ class SessionsViewModelTest {
@Test
fun `toggleHide updates hidden status and surfaces toast`() {
val vm = createViewModel()
coEvery { mockApi.getSessions(any(), any(), any()) } returns
coEvery { mockApi.getSessions(any(), any(), any(), null, "cron") } returns
Response.success(
SessionListResponse(
sessions = listOf(SessionInfo("s-1", hidden = false)),