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

@@ -9,6 +9,7 @@ import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
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
@@ -468,6 +469,135 @@ class HermesApiServiceMockWebServerTest {
)
}
@Test
fun getSessionTimeline_sendsCursorAndParsesFinalPageWithUnknownFields() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"session_id": "desktop/session",
"profile": "work",
"future_top_level_field": "ignored",
"entries": [
{
"row_id": 91,
"preview": "First compacted prompt",
"timestamp": 1718000000,
"future_entry_field": {"anything": true}
}
],
"pagination": {
"limit": 500,
"after_row_id": 77,
"returned": 1,
"total": 1,
"has_more": false,
"next_cursor": null,
"future_pagination_field": "ignored"
}
}
""".trimIndent(),
),
)
val response =
api.getSessionTimeline(
sessionId = "desktop/session",
profile = "work",
limit = 500,
afterRowId = 77,
)
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
assertEquals(91, body!!.entries.single().row_id)
assertEquals("First compacted prompt", body.entries.single().preview)
assertFalse(body.pagination.has_more)
assertNull(body.pagination.next_cursor)
assertEquals(
"/api/sessions/desktop/session/timeline?profile=work&limit=500&after_row_id=77",
mockServer.takeRequest().path,
)
}
@Test
fun getSessionMessagesAround_sendsStableRowAndPreservesChronologicalOrder() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"session_id": "desktop/session",
"profile": "work",
"messages": [
{
"id": 42,
"role": "user",
"content": "[model-facing compaction summary]",
"display_content": "target"
},
{"id": 43, "role": "assistant", "content": "answer"}
],
"pagination": {
"row_id": 42,
"limit": 120,
"returned": 2,
"order": "oldest",
"offset": 7,
"total": 100,
"has_older": true,
"has_newer": true,
"future_field": 123
}
}
""".trimIndent(),
),
)
val response =
api.getSessionMessagesAround(
sessionId = "desktop/session",
rowId = 42,
profile = "work",
limit = 120,
)
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
assertEquals(listOf(42, 43), body!!.messages.map { it.id })
assertEquals("target", body.messages.first().displayContentText)
assertEquals("oldest", body.pagination.order)
assertEquals(7, body.pagination.offset)
assertEquals(true, body.pagination.has_older)
assertEquals(true, body.pagination.has_newer)
assertEquals(
"/api/sessions/desktop/session/messages/around?row_id=42&profile=work&limit=120",
mockServer.takeRequest().path,
)
}
@Test
fun getSessionMessagesAround_missingPromptReturns404() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(404)
.setBody("""{"detail":"Prompt not found"}"""),
)
val response = api.getSessionMessagesAround(sessionId = "session-1", rowId = 999)
assertFalse(response.isSuccessful)
assertEquals(404, response.code())
}
@Test
fun getOAuthProviders_parsesList() =
runBlocking {
@@ -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