12 files changed
+1124
-34
No files matched your search
+130
@@ -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
|
||||
|
||||
Reference in new issue
Block a user