feat(#1217): add compacted-session timeline navigation (#1227)

This commit is contained in:
m57 authored and GitHub committed 2026-09-22 18:29:06 +03:00
1 parent 2e6ae95fe0
commit 16967c7e91
12 files changed
+1124 -34

No files matched your search

@@ -12,7 +12,12 @@ import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.PaginationInfo
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.model.SessionMessagesAroundPagination
import com.m57.hermescontrol.data.model.SessionMessagesAroundResponse
import com.m57.hermescontrol.data.model.SessionMessagesResponse
import com.m57.hermescontrol.data.model.SessionTimelineEntry
import com.m57.hermescontrol.data.model.SessionTimelinePagination
import com.m57.hermescontrol.data.model.SessionTimelineResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.GatewayFile
import com.m57.hermescontrol.data.remote.GatewayFileClient
@@ -202,6 +207,28 @@ class ChatViewModelTest {
assertFalse(mapped.single().content.contains("THE_SECRET_WORD_IS_MANGO_8421"))
}
@Test
fun mapServerMessages_prefersCompactionDisplayContent() {
val mapped =
mapServerMessages(
sessionId = "session-1",
messages =
listOf(
SessionMessage(
id = 42,
role = "user",
content = JsonPrimitive("[model-facing compaction summary]"),
display_content = JsonPrimitive("The original user prompt"),
),
),
offset = 0,
latestPaging = true,
liveMessages = emptyList(),
)
assertEquals("The original user prompt", mapped.single().content)
}
@Test
fun stripAttachmentRefLines_preservesUserAuthoredAttachedContextHeading() {
val authored =
@@ -6601,6 +6628,274 @@ class ChatViewModelTest {
every { AuthManager.activeProfileId } returns MutableStateFlow<String?>("default")
}
@Test
fun timelinePagesUseBackendCursorAndStopOnFinalPage() =
runTest {
stubActiveProfile()
val (viewModel, sessionId) = createViewModelWithSession()
val api = ApiClient.hermesApi
coEvery {
api.getSessionTimeline(sessionId, "default", 500, 0)
} returns
retrofit2.Response.success(
SessionTimelineResponse(
entries = listOf(SessionTimelineEntry(row_id = 10, preview = "first")),
pagination =
SessionTimelinePagination(
limit = 500,
after_row_id = 0,
returned = 1,
total = 2,
has_more = true,
next_cursor = 123,
),
),
)
coEvery {
api.getSessionTimeline(sessionId, "default", 500, 123)
} returns
retrofit2.Response.success(
SessionTimelineResponse(
entries = listOf(SessionTimelineEntry(row_id = 200, preview = "second")),
pagination =
SessionTimelinePagination(
limit = 500,
after_row_id = 123,
returned = 1,
total = 2,
has_more = false,
next_cursor = null,
),
),
)
viewModel.openTimeline()
advanceUntilIdle()
assertEquals(
listOf(10),
viewModel.timelineState.value.entries
.map { it.row_id },
)
assertEquals(123, viewModel.timelineState.value.nextCursor)
assertTrue(viewModel.timelineState.value.hasMore)
viewModel.loadMoreTimeline()
advanceUntilIdle()
assertEquals(
listOf(10, 200),
viewModel.timelineState.value.entries
.map { it.row_id },
)
assertFalse(viewModel.timelineState.value.hasMore)
assertNull(viewModel.timelineState.value.nextCursor)
coVerify(exactly = 1) { api.getSessionTimeline(sessionId, "default", 500, 123) }
}
@Test
fun timelineJumpUsesBoundedHistoryWindowWithoutReplacingLiveTail() =
runTest {
stubActiveProfile()
val (viewModel, sessionId) = createViewModelWithSession()
val api = ApiClient.hermesApi
val persistedBeforeJump = fakeRepo.dao.count()
val liveMessages = viewModel.uiState.value.messages
coEvery {
api.getSessionMessagesAround(sessionId, 42, "default", 120)
} returns
retrofit2.Response.success(
SessionMessagesAroundResponse(
messages =
listOf(
SessionMessage(id = 42, role = "user", content = JsonPrimitive("target")),
SessionMessage(id = 43, role = "assistant", content = JsonPrimitive("answer")),
),
pagination =
SessionMessagesAroundPagination(
row_id = 42,
limit = 120,
returned = 2,
order = "oldest",
offset = 7,
total = 100,
has_older = true,
has_newer = true,
),
),
)
viewModel.jumpToTimelineEntry(42)
advanceUntilIdle()
val timeline = viewModel.timelineState.value
assertTrue(timeline.isHistorical)
assertEquals(42, timeline.historyAnchorRowId)
assertTrue(timeline.historyHasOlder)
assertTrue(timeline.historyHasNewer)
assertEquals(
listOf("rest-$sessionId-42", "rest-$sessionId-43"),
timeline.historyMessages!!.map { it.id },
)
assertEquals(listOf("target", "answer"), timeline.historyMessages.map { it.content })
assertEquals(liveMessages, viewModel.uiState.value.messages)
// Direct-address history is a replaceable display window, not a
// contiguous live-cache page. Persisting it would create fake gaps.
assertEquals(persistedBeforeJump, fakeRepo.dao.count())
viewModel.returnToLatestMessages()
assertFalse(viewModel.timelineState.value.isHistorical)
assertNull(viewModel.timelineState.value.historyMessages)
assertFalse(viewModel.timelineState.value.historyHasOlder)
assertFalse(viewModel.timelineState.value.historyHasNewer)
}
@Test
fun missingTimelineRowKeepsCurrentHistoryWindow() =
runTest {
stubActiveProfile()
val (viewModel, sessionId) = createViewModelWithSession()
val api = ApiClient.hermesApi
coEvery {
api.getSessionMessagesAround(sessionId, 42, "default", 120)
} returns
retrofit2.Response.success(
SessionMessagesAroundResponse(
messages = listOf(SessionMessage(id = 42, role = "user", content = JsonPrimitive("target"))),
pagination = SessionMessagesAroundPagination(row_id = 42),
),
)
coEvery {
api.getSessionMessagesAround(sessionId, 999, "default", 120)
} returns
retrofit2.Response.error(
404,
"""{"detail":"Prompt not found"}""".toResponseBody(),
)
viewModel.jumpToTimelineEntry(42)
advanceUntilIdle()
val originalWindow = viewModel.timelineState.value.historyMessages
viewModel.jumpToTimelineEntry(999)
advanceUntilIdle()
assertEquals(42, viewModel.timelineState.value.historyAnchorRowId)
assertEquals(originalWindow, viewModel.timelineState.value.historyMessages)
assertNotNull(viewModel.timelineState.value.windowErrorMessage)
}
@Test
fun retryTimelineClearsWindowErrorBeforeRefreshing() =
runTest {
stubActiveProfile()
val (viewModel, sessionId) = createViewModelWithSession()
val api = ApiClient.hermesApi
coEvery {
api.getSessionTimeline(sessionId, "default", 500, 0)
} returns
retrofit2.Response.success(
SessionTimelineResponse(
entries = listOf(SessionTimelineEntry(row_id = 42, preview = "target")),
pagination = SessionTimelinePagination(has_more = false),
),
)
coEvery {
api.getSessionMessagesAround(sessionId, 42, "default", 120)
} returns
retrofit2.Response.error(
404,
"""{"detail":"Prompt not found"}""".toResponseBody(),
)
viewModel.openTimeline()
advanceUntilIdle()
viewModel.jumpToTimelineEntry(42)
advanceUntilIdle()
assertNotNull(viewModel.timelineState.value.windowErrorMessage)
viewModel.retryTimeline()
assertNull(viewModel.timelineState.value.windowErrorMessage)
advanceUntilIdle()
assertNull(viewModel.timelineState.value.windowErrorMessage)
assertEquals(
listOf(42),
viewModel.timelineState.value.entries
.map { it.row_id },
)
coVerify(exactly = 2) { api.getSessionTimeline(sessionId, "default", 500, 0) }
}
@Test
fun profileSwitchRejectsLateTimelineJumpResponse() =
runTest {
val profileFlow = MutableStateFlow<String?>("default")
every { AuthManager.activeProfileId } returns profileFlow
val (viewModel, sessionId) = createViewModelWithSession()
val api = ApiClient.hermesApi
val response = CompletableDeferred<retrofit2.Response<SessionMessagesAroundResponse>>()
coEvery {
api.getSessionMessagesAround(sessionId, 42, "default", 120)
} coAnswers {
response.await()
}
viewModel.jumpToTimelineEntry(42)
runCurrent()
profileFlow.value = "other"
mockSwitchFlow.emit("other")
runCurrent()
response.complete(
retrofit2.Response.success(
SessionMessagesAroundResponse(
messages = listOf(SessionMessage(id = 42, role = "user", content = JsonPrimitive("stale"))),
pagination = SessionMessagesAroundPagination(row_id = 42),
),
),
)
advanceUntilIdle()
assertNull(viewModel.uiState.value.currentSessionId)
assertFalse(viewModel.timelineState.value.isOpen)
assertNull(viewModel.timelineState.value.historyMessages)
assertNull(viewModel.timelineState.value.historyAnchorRowId)
}
@Test
fun sessionSwitchRejectsLateTimelineJumpResponse() =
runTest {
stubActiveProfile()
val (viewModel, sessionId) = createViewModelWithSession()
val api = ApiClient.hermesApi
val response = CompletableDeferred<retrofit2.Response<SessionMessagesAroundResponse>>()
coEvery {
api.getSessionMessagesAround(sessionId, 42, "default", 120)
} coAnswers {
response.await()
}
viewModel.jumpToTimelineEntry(42)
runCurrent()
viewModel.switchSession("session-other")
runCurrent()
response.complete(
retrofit2.Response.success(
SessionMessagesAroundResponse(
messages = listOf(SessionMessage(id = 42, role = "user", content = JsonPrimitive("stale"))),
pagination = SessionMessagesAroundPagination(row_id = 42),
),
),
)
runCurrent()
assertEquals("session-other", viewModel.uiState.value.currentSessionId)
assertFalse(viewModel.timelineState.value.isOpen)
assertNull(viewModel.timelineState.value.historyMessages)
assertNull(viewModel.timelineState.value.historyAnchorRowId)
}
/**
* The REST high-watermark must be read BEFORE prompt.submit leaves the
* device. Capturing it afterwards lets a fast turn persist its assistant row