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
|
||||
|
||||
Reference in new issue
Block a user