feat(chat): add paged history with stable transcript and scroll identity (#1211)

Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
AlexDev_andm57 authored and GitHub committed 2026-09-22 14:42:03 +03:00
1 parent 83b2a8fa9b
commit a78b20bc99
26 files changed
+3942 -884

No files matched your search

@@ -0,0 +1,617 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao
import com.m57.hermescontrol.ui.chat.fullbleed.fullBleedItemKeys
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatPagingMergeTest {
@Test
fun confirmedLiveOccurrenceCannotConsumeEarlierIdenticalPage() {
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) {
val content = if (role == MessageRole.TOOL) "{\"output\":\"ok\"}" else "continue"
val live = ChatMessage(id = "uuid-new", role = role, content = content, timestamp = 2L)
val newest = live.copy(id = "rest-s-20")
val earlier = live.copy(id = "rest-s-10", timestamp = 1L)
val hydrated = mergeTranscriptWithLive(listOf(newest), listOf(live), preserveLiveIds = true)
val paged =
mergeTranscriptWithLive(
listOf(earlier),
hydrated,
chronological = false,
preserveLiveIds = true,
)
assertEquals("Distinct $role occurrences must survive across pages", 2, paged.size)
assertEquals(listOf("rest-s-10", "uuid-new"), paged.map { it.id })
val refreshed = mergeTranscriptWithLive(listOf(newest), paged, preserveLiveIds = true)
assertEquals(2, refreshed.size)
assertEquals("uuid-new", refreshed.last().id)
}
}
@Test
fun mapperAndMergeKeepConfirmedOccurrencesMetadataAndKeysAcrossPages() {
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) {
val content = if (role == MessageRole.TOOL) """{"output":"ok"}""" else "continue"
val live =
ChatMessage(
id = "uuid-new",
role = role,
content = content,
timestamp = 100L,
reasoningText = "live reasoning",
toolName = "terminal",
toolStatus = ToolStatus.COMPLETED,
attachments =
listOf(
Attachment(uri = "content://image", name = "image", mimeType = "image/png", size = 1),
),
finishTimestamp = 200L,
tps = 5.0,
)
fun page(
id: Int,
current: List<ChatMessage>,
): List<ChatMessage> =
mapServerMessages(
"s",
listOf(SessionMessage(id = id, role = role.name.lowercase(), content = JsonPrimitive(content))),
0,
true,
current,
)
val hydrated = mergeTranscriptWithLive(page(20, listOf(live)), listOf(live), preserveLiveIds = true)
val confirmed = hydrated.single()
assertEquals("rest-s-20", confirmed.restId)
assertEquals(live.id, confirmed.id)
assertEquals(live.reasoningText, confirmed.reasoningText)
assertEquals(live.attachments, confirmed.attachments)
assertEquals(live.finishTimestamp, confirmed.finishTimestamp)
assertEquals(live.tps, confirmed.tps)
assertEquals(live.toolStatus, confirmed.toolStatus)
val refreshedPage = page(20, hydrated)
assertEquals(confirmed.timestamp, refreshedPage.single().timestamp)
// Missing server timestamps must not put this older occurrence after the UUID alias.
val paged =
mergeTranscriptWithLive(page(10, hydrated), hydrated, chronological = false, preserveLiveIds = true)
assertEquals(listOf("rest-s-10", "uuid-new"), paged.map { it.id })
assertEquals("", paged.first().reasoningText)
val refreshed =
mergeTranscriptWithLive(page(10, paged), paged, chronological = false, preserveLiveIds = true)
assertEquals(paged, refreshed)
assertEquals("rest-s-20", refreshed.last().restId)
}
}
@Test
fun cacheAliasesRejectEarlierOccurrencesInEitherPageOrder() {
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) {
val content = if (role == MessageRole.TOOL) """{"output":"ok"}""" else "continue"
val live = ChatMessage(id = "uuid", role = role, content = content, timestamp = 20L)
val echo = live.copy(id = "rest-s-20")
val earlier = live.copy(id = "rest-s-10", timestamp = 10L)
for (firstPage in listOf(listOf(live, echo), listOf(echo, live))) {
val cached = dedupeCachedMessages(firstPage)
assertEquals(listOf(live.copy(restId = echo.id)), cached)
val nextPage = dedupeCachedMessages(listOf(earlier) + cached)
assertEquals(listOf(earlier, live.copy(restId = echo.id)), nextPage)
assertEquals(nextPage, dedupeCachedMessages(listOf(echo) + nextPage))
}
}
}
@Test
fun cachedPagesPreserveDisplayedKeyAndEnrichAliasInEitherArrivalOrder() {
val live = tool("uuid", timestamp = 20L).copy(toolName = "terminal")
val echo = tool("rest-s-20", timestamp = 20L)
val earlier = tool("rest-s-10", timestamp = 10L)
for ((first, second) in listOf(live to echo, echo to live)) {
val merged = mergeCachedTranscriptPage(listOf(second), listOf(first))
assertEquals(listOf(live.copy(id = first.id, restId = echo.id)), merged)
val older = mergeCachedTranscriptPage(listOf(earlier), merged)
assertEquals(listOf(earlier.id, first.id), older.map { it.id })
assertEquals(older, mergeCachedTranscriptPage(listOf(echo, live), older))
}
assertEquals(
listOf(earlier, live.copy(restId = echo.id)),
mergeCachedTranscriptPage(listOf(earlier, live), listOf(live.copy(restId = echo.id))),
)
// A page-local dedup must also transfer its alias to an already displayed UUID.
assertEquals(
listOf(live.copy(restId = echo.id)),
mergeCachedTranscriptPage(listOf(echo, live), listOf(live)),
)
}
@Test
fun exactAliasIsReservedBeforeUnconfirmedContentAndSurvivesContentChanges() {
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) {
val content = if (role == MessageRole.TOOL) """{"output":"ok"}""" else "continue"
val live = ChatMessage(id = "uuid", role = role, content = content, restId = "rest-s-20")
val unconfirmed = live.copy(id = "uuid-next", restId = null)
val older = live.copy(id = "rest-s-10", restId = null)
val exact = live.copy(id = "rest-s-20", restId = null, content = "updated by server")
val matches = matchTranscriptMessages(listOf(older, exact), listOf(live, unconfirmed))
assertEquals(listOf(unconfirmed, live), matches)
assertTrue(!sameLogicalMessage(older, live))
}
}
@Test
fun olderRowsUseAliasBoundariesEvenWhenLocalTimestampsDisagree() {
val first = tool("uuid-first", timestamp = 100L).copy(restId = "rest-s-4")
val last = tool("uuid-last", timestamp = 1L).copy(restId = "rest-s-10")
val page = listOf(1, 5, 7).map { tool("rest-s-$it", timestamp = 50L) }
val merged = mergeTranscriptWithLive(page, listOf(first, last), chronological = false, preserveLiveIds = true)
assertEquals(listOf("rest-s-1", "uuid-first", "rest-s-5", "rest-s-7", "uuid-last"), merged.map { it.id })
}
private fun tool(
id: String,
callId: String = "",
timestamp: Long = 1L,
) = ChatMessage(
id = id,
role = MessageRole.TOOL,
content = """{"output":"ok","exit_code":0}""",
toolCallId = callId,
toolStatus = ToolStatus.COMPLETED,
timestamp = timestamp,
)
@Test
fun merge_oneRestEchoCannotConsumeTwoIdenticalLiveResults() {
val first = tool("ws-first", timestamp = 1L)
val second = tool("ws-second", timestamp = 2L)
val echo = tool("rest-session-1", timestamp = 1L)
val merged = mergeTranscriptWithLive(listOf(echo), listOf(first, second))
assertEquals("A result echo may consume at most one live occurrence", 2, merged.size)
assertTrue(merged.any { it.id == second.id })
}
@Test
fun cache_oneLiveEchoCannotConsumeTwoPersistedResults() {
val first = tool("rest-session-1", timestamp = 1L)
val second = tool("rest-session-2", timestamp = 2L)
val echo = tool("ws-first", timestamp = 1L)
val cached = dedupeCachedMessages(listOf(first, echo, second))
assertEquals(2, cached.size)
assertTrue(cached.any { it.id == second.id })
}
@Test
fun merge_identicalPayloadsWithDifferentCallIdsStayDistinct() {
val first = tool("rest-session-1", callId = "call-first")
val second = tool("ws-second", callId = "call-second")
val merged = mergeTranscriptWithLive(listOf(first), listOf(second))
assertEquals(setOf(first.id, second.id), merged.map { it.id }.toSet())
}
@Test
fun merge_largeSettledHistoryRetainsEveryStableId() {
val settled = (1..2_000).map { index -> tool("rest-session-$index", callId = "call-$index") }
val overlappingPage = settled.takeLast(150)
val live = tool("ws-new", callId = "call-new", timestamp = 2L)
val merged = mergeTranscriptWithLive(overlappingPage, settled + live)
assertEquals(2_001, merged.size)
assertEquals((settled + live).map { it.id }.toSet(), merged.map { it.id }.toSet())
}
@Test
fun olderOverlapUpdatesInPlaceWithoutMovingExistingRows() {
val current = (1..10).map { tool("rest-s-$it", callId = "call-$it", timestamp = 1L) }
val page = current.slice(4..5).map { it.copy(toolName = "terminal") }
val merged = mergeTranscriptWithLive(page, current, chronological = false)
assertEquals(current.map { it.id }, merged.map { it.id })
assertEquals("terminal", merged[4].toolName)
assertEquals("terminal", merged[5].toolName)
}
@Test
fun olderDisjointAndGapRowsUseServerOrderWhenTimestampsAreEqual() {
val current = listOf(4, 6, 10).map { tool("rest-s-$it", callId = "call-$it", timestamp = 1L) }
val page = listOf(1, 3, 5, 6, 7).map { tool("rest-s-$it", callId = "call-$it", timestamp = 1L) }
val merged = mergeTranscriptWithLive(page, current, chronological = false)
assertEquals(listOf(1, 3, 4, 5, 6, 7, 10).map { "rest-s-$it" }, merged.map { it.id })
}
private fun serverTool(
id: Int,
callId: String = "",
) = com.m57.hermescontrol.data.model.SessionMessage(
id = id,
role = "tool",
content = kotlinx.serialization.json.JsonPrimitive("""{"output":"ok","exit_code":0}"""),
tool_call_id = callId,
)
@Test
fun mapper_mixedRoomEchoPrefersRichWsToolOverExactRestId() {
val live =
tool("uuid-1", callId = "call-42").copy(
toolName = "terminal",
content = """{"name":"terminal","result":{"output":"ok","exit_code":0}}""",
)
val echo = tool("rest-s-42", callId = "call-42")
val mapped = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(live, echo))
assertEquals(listOf(live.copy(restId = "rest-s-42")), mapped)
}
@Test
fun mapper_mixedEchoesConsumeIdenticalResultsOnlyOncePerOccurrence() {
val first = tool("uuid-1").copy(toolName = "terminal")
val second = tool("uuid-2").copy(toolName = "terminal")
val cache = listOf(first, second, tool("rest-s-42"), tool("rest-s-43"))
val mapped = mapServerMessages("s", listOf(serverTool(42), serverTool(43)), 0, true, cache)
assertEquals(
listOf(first.copy(restId = "rest-s-42"), second.copy(restId = "rest-s-43")),
mapped,
)
assertEquals(2, mapped.map { it.id }.toSet().size)
}
@Test
fun mapper_differentCallIdsCannotReuseIdenticalLiveResult() {
val live = tool("uuid-1", callId = "call-41").copy(toolName = "terminal")
val echo = tool("rest-s-42", callId = "call-42")
val mapped = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(live, echo))
assertEquals("rest-s-42", mapped.single().id)
assertEquals("call-42", mapped.single().toolCallId)
assertEquals(null, mapped.single().toolName)
}
@Test
fun cacheAndRestMergesKeepNumericOrderDespiteNonMonotonicTimestamps() {
val rows =
listOf(9, 10, 100, 200).mapIndexed { index, id ->
ChatMessage(
id = "rest-session-$id",
role = MessageRole.ASSISTANT,
content = "answer-$id",
timestamp = listOf(900L, 100L, 800L, 200L)[index],
)
}
for (cache in listOf(false, true)) {
for (newestFirst in listOf(false, true)) {
val first = if (newestFirst) rows.drop(2) else rows.take(2)
val second = if (newestFirst) rows.take(2) else rows.drop(2)
val merged =
if (cache) {
mergeCachedTranscriptPage(second, mergeCachedTranscriptPage(first, emptyList()))
} else {
mergeTranscriptWithLive(
second,
mergeTranscriptWithLive(first, emptyList(), preserveLiveIds = true),
chronological = !newestFirst,
preserveLiveIds = true,
)
}
assertEquals("cache=$cache newestFirst=$newestFirst", rows.map { it.id }, merged.map { it.id })
}
}
}
@Test
fun mapperAssignsOneLiveTraceOnlyToNewestEqualContentRestRow() {
for (completion in listOf(null, "completion-200")) {
val live =
ChatMessage(
id = "uuid",
role = MessageRole.ASSISTANT,
content = "Done",
reasoningText = "newest trace",
completionId = completion,
)
val mapped =
mapServerMessages(
"session",
listOf(assistantRow(100), assistantRow(200)),
0,
true,
listOf(live),
)
assertEquals(listOf("", "newest trace"), mapped.map { it.reasoningText })
assertEquals(listOf(null, completion), mapped.map { it.completionId })
val merged = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true)
assertEquals(listOf("rest-session-100", "uuid"), merged.map { it.id })
assertEquals(listOf("", "newest trace"), merged.map { it.reasoningText })
assertEquals("rest-session-200", merged.last().canonicalRestId)
}
}
@Test
fun mapperReservesCanonicalAndCompletionReasoningBeforeContentFallback() {
val first =
ChatMessage(
id = "uuid-first",
role = MessageRole.ASSISTANT,
content = "Done",
reasoningText = "first trace",
completionId = "completion-100",
)
val second = first.copy(id = "uuid-second", reasoningText = "second trace", completionId = "completion-200")
for (canonicalFirst in listOf(false, true)) {
val live = listOf(if (canonicalFirst) first.copy(restId = "rest-session-100") else first, second)
val mapped =
mapServerMessages(
"session",
listOf(assistantRow(100), assistantRow(200)),
0,
true,
live,
)
assertEquals(listOf("first trace", "second trace"), mapped.map { it.reasoningText })
assertEquals(listOf("completion-100", "completion-200"), mapped.map { it.completionId })
}
}
@Test
fun mapperUsesNewestReasoningCandidateWhenNoCompletionIdentityExists() {
val first =
ChatMessage(id = "uuid-first", role = MessageRole.ASSISTANT, content = "Done", reasoningText = "old")
val second = first.copy(id = "uuid-second", reasoningText = "new")
val mapped = mapServerMessages("session", listOf(assistantRow(200)), 0, true, listOf(first, second))
assertEquals("new", mapped.single().reasoningText)
}
@Test
fun olderPagesCannotStealLiveReasoningInEitherArrivalOrder() {
val live =
ChatMessage(
id = "uuid",
role = MessageRole.ASSISTANT,
content = "Done",
reasoningText = "newest trace",
completionId = "completion-200",
timestamp = 200_000L,
)
for (newestFirst in listOf(false, true)) {
val order = if (newestFirst) listOf(200, 100) else listOf(100, 200)
var current = listOf(live)
order.forEach { id ->
current = applyServerPage(current, listOf(assistantRow(id)), older = id == 100)
assertEquals("A trace may appear only once after page $id", 1, current.count { it.reasoningText != "" })
if (id ==
100
) {
assertEquals("", current.single { it.canonicalRestId == "rest-session-100" }.reasoningText)
}
}
assertEquals(listOf("rest-session-100", "uuid"), current.map { it.id })
assertEquals(listOf("", "newest trace"), current.map { it.reasoningText })
assertEquals(listOf(null, "completion-200"), current.map { it.completionId })
assertEquals("rest-session-200", current.last().canonicalRestId)
}
// Older paging also forbids text-only reasoning fallback without a completion id.
val older =
mapServerMessages(
"session",
listOf(assistantRow(100)),
150,
true,
listOf(live.copy(completionId = null)),
isPagingOlder = true,
)
assertEquals("", older.single().reasoningText)
}
@Test
fun reasoningOnlyBoundaryRetainsCanonicalRowBeforeAnswerArrives() {
val previous = assistantRow(90, content = "Previous answer")
val reasoning = assistantRow(100, content = "", reasoning = "boundary trace")
val mapped = mapServerMessages("session", listOf(previous, reasoning), 0, true, emptyList())
assertEquals(listOf("rest-session-90", "rest-session-100"), mapped.map { it.canonicalRestId })
assertEquals("", mapped.first().reasoningText)
assertEquals("boundary trace", mapped.last().reasoningText)
assertEquals("", mapped.last().content)
}
@Test
fun splitReasoningAndAnswerSurviveBothArrivalOrdersRefreshAndPagedCacheReload() =
runTest {
val reasoning = assistantRow(100, content = "", reasoning = "boundary trace")
val answer = assistantRow(200)
for (answerFirst in listOf(false, true)) {
val dao = FakeChatMessageDao()
val repository = ChatPersistenceRepository(dao)
var current = emptyList<ChatMessage>()
val pages = if (answerFirst) listOf(answer, reasoning) else listOf(reasoning, answer)
pages.forEach { row ->
val mapped =
mapServerMessages(
"session",
listOf(row),
0,
true,
current,
isPagingOlder = answerFirst && row.id == 100,
)
current =
mergeTranscriptWithLive(
mapped,
current,
chronological = !(answerFirst && row.id == 100),
preserveLiveIds = true,
)
// Same input that persistHistoryPage writes, including the boundary page before its neighbor.
repository.persistMessages(
mapped.map { it.copy(id = requireNotNull(it.canonicalRestId)) },
"session",
)
}
assertBoundaryTranscript(current)
val refreshed = applyServerPage(current, listOf(reasoning, answer))
assertEquals(current, refreshed)
val latest = repository.loadPage("session", null, 1)
val older = repository.loadPage("session", latest.cursor, 1)
assertEquals(true, latest.hasOlder)
assertEquals(false, older.hasOlder)
for ((first, second) in listOf(latest.messages to older.messages, older.messages to latest.messages)) {
assertBoundaryTranscript(
mergeCachedTranscriptPage(second, mergeCachedTranscriptPage(first, emptyList())),
)
}
assertEquals(0, dao.fullSessionReads)
assertEquals(listOf(2, 2), dao.pageLimits)
}
}
@Test
fun reasoningDoesNotCrossUserOrToolBoundaryInEitherArrivalOrder() {
for (role in listOf("user", "tool")) {
val reasoning = assistantRow(100, content = "", reasoning = "before $role")
val boundary =
SessionMessage(
id = 150,
role = role,
content = JsonPrimitive("boundary"),
timestamp = JsonPrimitive(150),
tool_call_id = if (role == "tool") "call-150" else null,
)
val answer = assistantRow(200)
for (newestFirst in listOf(false, true)) {
val first = if (newestFirst) listOf(boundary, answer) else listOf(reasoning)
val second = if (newestFirst) listOf(reasoning) else listOf(boundary, answer)
val merged = applyServerPage(applyServerPage(emptyList(), first), second, older = newestFirst)
assertEquals(
listOf(100, 150, 200).map { "rest-session-$it" },
merged.map { it.canonicalRestId },
)
assertEquals(listOf("before $role", "", ""), merged.map { it.reasoningText })
// Mapping all rows together must have the same ownership as separate pages.
assertEquals(merged, applyServerPage(merged, listOf(reasoning, boundary, answer)))
}
}
}
@Test
fun multipleReasoningOnlyRowsRemainVisibleAcrossToolSteps() {
val rows =
listOf(
assistantRow(100, content = "", reasoning = "first trace"),
SessionMessage(
id = 150,
role = "tool",
content = JsonPrimitive("result"),
timestamp = JsonPrimitive(150),
),
assistantRow(200, content = "", reasoning = "second trace"),
assistantRow(300),
)
val mapped = applyServerPage(emptyList(), rows)
assertEquals(listOf("first trace", "second trace"), mapped.map { it.reasoningText }.filter { it.isNotBlank() })
assertEquals(
listOf("reasoning-rest-session-100", "reasoning-rest-session-200"),
fullBleedItemKeys(groupIntoTurns(mapped)).filter { it.startsWith("reasoning-") },
)
assertEquals(1, fullBleedItemKeys(groupIntoTurns(mapped)).count { it.startsWith("prose-") })
}
@Test
fun canonicalReasoningAndMatchingLiveAnswerRenderTraceOnlyOnceInEitherArrivalOrder() {
val live =
ChatMessage(
id = "uuid-answer",
role = MessageRole.ASSISTANT,
content = "Done",
reasoningText = "boundary trace",
completionId = "completion-200",
timestamp = 200_000L,
)
val reasoning = assistantRow(100, content = "", reasoning = "boundary trace")
val answer = assistantRow(200)
for (answerFirst in listOf(false, true)) {
val pages = if (answerFirst) listOf(answer, reasoning) else listOf(reasoning, answer)
var current = listOf(live)
pages.forEach { row ->
current = applyServerPage(current, listOf(row), older = row.id == 100)
}
assertEquals(listOf("rest-session-100", "uuid-answer"), current.map { it.id })
assertEquals(listOf("boundary trace", ""), current.map { it.reasoningText })
assertEquals("completion-200", current.last().completionId)
assertEquals("rest-session-200", current.last().canonicalRestId)
assertEquals(
listOf("reasoning-rest-session-100", "prose-uuid-answer"),
fullBleedItemKeys(groupIntoTurns(current)),
)
}
}
@Test
fun differentReasoningOnlyRowsCannotMatchByEmptyContent() {
val live =
ChatMessage(
id = "uuid-thinking",
role = MessageRole.ASSISTANT,
content = "",
reasoningText = "new trace",
)
val canonical = live.copy(id = "rest-session-100", reasoningText = "old trace")
assertTrue(!sameLogicalMessage(canonical, live))
assertEquals(2, mergeTranscriptWithLive(listOf(canonical), listOf(live), preserveLiveIds = true).size)
assertTrue(sameLogicalMessage(canonical.copy(reasoningText = "new trace"), live))
}
private fun assertBoundaryTranscript(messages: List<ChatMessage>) {
assertEquals(listOf("rest-session-100", "rest-session-200"), messages.map { it.canonicalRestId })
assertEquals(listOf("boundary trace", ""), messages.map { it.reasoningText })
assertEquals(listOf("", "Done"), messages.map { it.content })
assertEquals(
listOf("reasoning-rest-session-100", "prose-rest-session-200"),
fullBleedItemKeys(groupIntoTurns(messages)),
)
}
private fun assistantRow(
id: Int,
content: String = "Done",
reasoning: String = "",
) = SessionMessage(
id = id,
role = "assistant",
content = JsonPrimitive(content),
reasoning = JsonPrimitive(reasoning),
timestamp = JsonPrimitive(id),
)
private fun applyServerPage(
current: List<ChatMessage>,
rows: List<SessionMessage>,
older: Boolean = false,
): List<ChatMessage> =
mergeTranscriptWithLive(
mapServerMessages("session", rows, 0, true, current, isPagingOlder = older),
current,
chronological = !older,
preserveLiveIds = true,
)
}
@@ -46,6 +46,36 @@ class ChatPersistenceRepositoryTest {
org.junit.Assert.assertSame(failure, runCatching { failing.loadMessages("s") }.exceptionOrNull())
}
@Test
fun pagedReadWaitsForLazyProviderAndRetainsCompletionIdentity() =
runTest {
val ready = kotlinx.coroutines.CompletableDeferred<com.m57.hermescontrol.data.local.ChatMessageDao>()
var calls = 0
val lazyRepository =
ChatPersistenceRepository {
calls++
ready.await()
}
assertEquals(0, calls)
val pending = async { lazyRepository.loadPage("s", null, 150) }
kotlinx.coroutines.yield()
assertEquals(1, calls)
org.junit.Assert.assertFalse(pending.isCompleted)
repository.persistMessage(
ChatMessage(id = "rest-s-1", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp"),
"s",
)
ready.complete(dao)
val row = pending.await().messages.single()
assertEquals("rest-s-1", row.canonicalRestId)
assertEquals("comp", row.completionId)
assertEquals(0, dao.fullSessionReads)
assertEquals(listOf(151), dao.pageLimits)
val failure = IllegalStateException("database unavailable")
val failing = ChatPersistenceRepository { throw failure }
org.junit.Assert.assertSame(failure, runCatching { failing.loadPage("s", null, 150) }.exceptionOrNull())
}
@Before
fun setup() {
dao = FakeChatMessageDao()
@@ -146,4 +176,123 @@ class ChatPersistenceRepositoryTest {
assertEquals("msg-B1", loadedB[0].id)
assertEquals("msg-B2", loadedB[1].id)
}
@Test
fun loadPage_usesSeparateInitialAndCursorQueries() =
runTest {
val tracedDao = io.mockk.spyk(dao)
val pagedRepository = ChatPersistenceRepository(tracedDao)
(1..3).forEach { index ->
pagedRepository.persistMessage(
ChatMessage(id = "row-$index", role = MessageRole.USER, content = "$index", timestamp = 10L),
"session",
)
}
val first = pagedRepository.loadPage("session", before = null, limit = 2)
val second = pagedRepository.loadPage("session", before = first.cursor, limit = 2)
assertEquals(listOf("row-2", "row-3"), first.messages.map { it.id })
assertEquals(listOf("row-1"), second.messages.map { it.id })
assertEquals(true, first.hasOlder)
assertEquals(false, second.hasOlder)
io.mockk.coVerify(exactly = 1) { tracedDao.getLatestMessagePage("session", 3) }
io.mockk.coVerify(exactly = 1) { tracedDao.getMessagePage("session", 1, 2L, "row-2", 3) }
io.mockk.coVerify(exactly = 0) { tracedDao.getMessagesForSession(any()) }
}
@Test
fun loadPage_equalTimestampsAndLiveInsertKeepCursorDeterministicAndSessionsIsolated() =
runTest {
val ids = (1..350).map { "rest-session-$it" }
repository.persistMessages(
ids.map { ChatMessage(id = it, role = MessageRole.USER, content = it, timestamp = 10L) },
"session",
)
val first = repository.loadPage("session", before = null, limit = 150)
repository.persistMessage(
ChatMessage(id = "new", role = MessageRole.USER, content = "new", timestamp = 20L),
"session",
)
repository.persistMessage(
ChatMessage(id = "other", role = MessageRole.USER, content = "other", timestamp = 10L),
"other-session",
)
val second = repository.loadPage("session", before = first.cursor, limit = 150)
val third = repository.loadPage("session", before = second.cursor, limit = 150)
assertEquals(ids.takeLast(150), first.messages.map { it.id })
assertEquals(ids, (third.messages + second.messages + first.messages).map { it.id })
assertEquals(false, third.hasOlder)
assertEquals(listOf(151, 151, 151), dao.pageLimits)
assertEquals(0, dao.fullSessionReads)
assertEquals(ids.toSet() + "new", dao.idsForSession("session"))
assertEquals(setOf("other"), dao.idsForSession("other-session"))
}
@Test
fun loadPage_nonMonotonicTimestampsFollowServerIdsInEitherArrivalOrder() =
runTest {
val rows =
listOf(9, 10, 100, 200).mapIndexed { index, id ->
ChatMessage(
id = "rest-session-$id",
role = MessageRole.ASSISTANT,
content = "answer-$id",
timestamp = listOf(900L, 100L, 800L, 200L)[index],
)
}
for (pages in listOf(listOf(rows.take(2), rows.drop(2)), listOf(rows.drop(2), rows.take(2)))) {
repository.clearMessagesForSession("session")
pages.forEach { repository.persistMessages(it, "session") }
val latest = repository.loadPage("session", null, 2)
val older = repository.loadPage("session", latest.cursor, 2)
assertEquals(rows.drop(2).map { it.id }, latest.messages.map { it.id })
assertEquals(rows.take(2).map { it.id }, older.messages.map { it.id })
assertEquals(true, latest.hasOlder)
assertEquals(false, older.hasOlder)
}
assertEquals(0, dao.fullSessionReads)
assertEquals(listOf(3, 3, 3, 3), dao.pageLimits)
}
@Test
fun loadPage_uuidOnlyRowsKeepInsertionOrderAcrossUpdatesAndLiveAppend() =
runTest {
val rows =
listOf("ffffffff", "00000000", "aaaaaaaa").mapIndexed { index, prefix ->
ChatMessage(
id = "$prefix-0000-4000-8000-000000000000",
role = MessageRole.USER,
content = "message-$index",
timestamp = listOf(900L, 100L, 800L)[index],
)
}
rows.forEach { repository.persistMessage(it, "local") }
repository.persistMessage(rows.first().copy(content = "edited", timestamp = 999L), "local")
val latest = repository.loadPage("local", null, 2)
repository.persistMessage(
ChatMessage(
id = "bbbbbbbb-0000-4000-8000-000000000000",
role = MessageRole.USER,
content = "appended after cursor",
timestamp = 1L,
),
"local",
)
val older = repository.loadPage("local", latest.cursor, 2)
assertEquals(rows.drop(1).map { it.id }, latest.messages.map { it.id })
assertEquals(listOf(rows.first().id), older.messages.map { it.id })
assertEquals("edited", older.messages.single().content)
assertEquals(false, older.hasOlder)
assertEquals(
"appended after cursor",
repository
.loadPage("local", null, 1)
.messages
.single()
.content,
)
assertEquals(0, dao.fullSessionReads)
}
}
@@ -4,7 +4,6 @@ import com.m57.hermescontrol.data.ws.WsEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -193,7 +192,7 @@ class ChatToolDedupeTest {
assertEquals(3, deduped.size)
// WS copies win; rest- copies of the same logical message are dropped
assertTrue(deduped.contains(wsTool))
assertTrue(deduped.contains(wsTool.copy(restId = restTool.id)))
assertTrue(deduped.none { it.id == "rest-s-1" })
assertTrue(deduped.none { it.id == "rest-s-2" })
// rest- user kept when no WS copy exists
@@ -279,7 +278,7 @@ class ChatToolDedupeTest {
val merged = mergeTranscriptWithLive(listOf(rest), listOf(live))
// Preserves cache/live-only user flags such as continuesActiveTurn.
assertSame(live, merged.single())
assertEquals(live.copy(restId = rest.id), merged.single())
}
@Test
@@ -551,7 +550,7 @@ class ChatToolDedupeTest {
val merged = mergeIncrementalTranscriptPage(incoming, current, "s", 1)
assertEquals(listOf("retry", "retry"), merged.map { it.content })
assertSame(live, merged.last())
assertEquals(live.copy(restId = "rest-s-1"), merged.last())
}
// ── Issue #842: MCP/web tool rows (raw `<untrusted_tool_result>` text) ──
@@ -654,6 +653,6 @@ The following content was retrieved from an external source.
)
val deduped = dedupeCachedMessages(listOf(wsTool, restTool))
assertEquals(1, deduped.size)
assertEquals(wsTool, deduped.single())
assertEquals(wsTool.copy(restId = restTool.id), deduped.single())
}
}
@@ -335,10 +335,21 @@ class ChatViewModelTest {
// ── Helpers ──────────────────────────────────────────────────────────────
/** Create a ViewModel with the fake repo injected directly. */
private fun createViewModel(startCleanup: Boolean = false): ChatViewModel =
private fun createViewModel(
startCleanup: Boolean = false,
historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = testDispatcher,
): ChatViewModel =
// Both dispatchers injected — a real ioDispatcher would race the
// test scheduler (repo writes hop it) and shuffle RPC ordering.
ChatViewModel(app, startCleanup, fakeRepo, fakeSlashUsageStore, testDispatcher, testDispatcher)
ChatViewModel(
app,
startCleanup,
fakeRepo,
fakeSlashUsageStore,
testDispatcher,
testDispatcher,
historyDispatcher,
)
/**
* Create ViewModel, simulate GatewayReady, feed SESSION_CREATE result,
@@ -349,8 +360,9 @@ class ChatViewModelTest {
*/
private suspend fun TestScope.createViewModelWithSession(
startCleanup: Boolean = false,
historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = testDispatcher,
): Pair<ChatViewModel, String> {
val viewModel = createViewModel(startCleanup)
val viewModel = createViewModel(startCleanup, historyDispatcher)
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
@@ -5198,6 +5210,824 @@ class ChatViewModelTest {
)
}
// Chat paging: deterministic regressions, with no real network or wall-clock timing.
private fun seedPagingCache(
count: Int,
sameTimestamp: Boolean = false,
): List<String> =
(1..count).map { index ->
val id = "cached-${index.toString().padStart(5, '0')}"
fakeRepo.dao.addMessageDirect(
com.m57.hermescontrol.data.local.ChatMessageEntity(
id = id,
sessionId = "session-456",
role = "user",
content = "Cached message $index",
timestamp = if (sameTimestamp) 1L else index.toLong(),
),
)
id
}
private fun pagingResponse(
rows: IntRange,
offset: Int = 0,
): retrofit2.Response<com.m57.hermescontrol.data.model.SessionMessagesResponse> =
retrofit2.Response.success(
com.m57.hermescontrol.data.model.SessionMessagesResponse(
messages =
rows.map { index ->
com.m57.hermescontrol.data.model.SessionMessage(
id = index,
role = "assistant",
content = JsonPrimitive("Server message $index"),
timestamp = JsonPrimitive(index),
)
},
pagination =
com.m57.hermescontrol.data.model.PaginationInfo(
limit = 150,
offset = offset,
order = "latest",
returned = rows.count(),
),
),
)
@Test
fun paging_syncNewestRestEcho_preservesLiveIdAndCompletionInOneBubble() =
runTest {
val api = ApiClient.hermesApi
val (viewModel, _) = createViewModelWithSession()
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} returns pagingResponse(IntRange.EMPTY)
viewModel.switchSession("session-456")
advanceUntilIdle()
// A completed live reply has no confirmed REST identity until its echo arrives.
val live =
ChatMessage(
id = "live-reply",
role = MessageRole.ASSISTANT,
content = "Server message 42",
timestamp = 42_000L,
completionId = "comp",
restId = null,
)
@Suppress("UNCHECKED_CAST")
val state =
ChatViewModel::class.java
.getDeclaredField("_uiState")
.apply { isAccessible = true }
.get(viewModel) as MutableStateFlow<Any>
state.value = viewModel.uiState.value.copy(messages = listOf(live))
advanceUntilIdle()
assertNull(
viewModel.uiState.value.messages
.single()
.restId,
)
assertEquals(
"comp",
viewModel.uiState.value.messages
.single()
.completionId,
)
coEvery {
api.getSessionMessages("session-456", 150, 0, "latest", any())
} returns pagingResponse(42..42)
viewModel.syncCurrentSession()
advanceUntilIdle()
coVerify(exactly = 2) {
api.getSessionMessages("session-456", 150, 0, "latest", any())
}
val messages = viewModel.uiState.value.messages
assertEquals("The newest REST echo must confirm the live reply without adding a bubble", 1, messages.size)
val message = messages.single()
assertEquals("Server message 42", message.content)
assertEquals("live-reply", message.id)
assertEquals("rest-session-456-42", message.restId)
assertEquals("comp", message.completionId)
}
@Test
fun paging_largeCache_initialReadIsBoundedAtDao() =
runTest {
val expectedIds = seedPagingCache(10_000)
stubSession456Rests(success = false)
val (viewModel, _) = createViewModelWithSession()
viewModel.switchSession("session-456")
advanceUntilIdle()
assertEquals(
expectedIds.takeLast(150),
viewModel.uiState.value.messages
.map { it.id },
)
assertEquals("Loading all rows then taking a suffix is not paging", 0, fakeRepo.dao.fullSessionReads)
assertEquals(expectedIds.toSet(), fakeRepo.dao.idsForSession("session-456"))
assertTrue(viewModel.uiState.value.hasOlderMessages)
assertFalse(viewModel.uiState.value.isLoading)
}
@Test
fun paging_offlinePagesRetainEveryRowWithEqualTimestamps() =
runTest {
val expectedIds = seedPagingCache(350, sameTimestamp = true)
stubSession456Rests(success = false)
val (viewModel, _) = createViewModelWithSession()
viewModel.switchSession("session-456")
advanceUntilIdle()
assertEquals(
expectedIds.takeLast(150),
viewModel.uiState.value.messages
.map { it.id },
)
viewModel.loadOlderMessages()
advanceUntilIdle()
assertEquals(
expectedIds.takeLast(300),
viewModel.uiState.value.messages
.map { it.id },
)
assertTrue(viewModel.uiState.value.hasOlderMessages)
assertFalse(viewModel.uiState.value.isLoadingOlder)
viewModel.loadOlderMessages()
advanceUntilIdle()
assertEquals(
expectedIds,
viewModel.uiState.value.messages
.map { it.id },
)
assertFalse(viewModel.uiState.value.hasOlderMessages)
assertFalse(viewModel.uiState.value.isLoadingOlder)
assertEquals(expectedIds.toSet(), fakeRepo.dao.idsForSession("session-456"))
assertEquals(0, fakeRepo.dao.fullSessionReads)
}
@Test
fun paging_cacheEchoEnrichesExistingUuidBeforeEarlierRepeatedOccurrencesArrive() =
runTest {
val roles = listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)
for (index in 1..303) {
val occurrence =
when (index) {
in 1..3 -> index - 1
in 151..153 -> index - 151
in 301..303 -> index - 301
else -> null
}
val role = occurrence?.let { roles[it] } ?: MessageRole.SYSTEM
val id =
when (index) {
in 1..3 -> "rest-session-456-${10 + requireNotNull(occurrence)}"
in 151..153 -> "rest-session-456-${20 + requireNotNull(occurrence)}"
in 301..303 -> "uuid-${requireNotNull(occurrence)}"
else -> "filler-$index"
}
fakeRepo.dao.addMessageDirect(
com.m57.hermescontrol.data.local.ChatMessageEntity(
id = id,
sessionId = "session-456",
role = role.name,
content =
when (role) {
MessageRole.TOOL -> """{"output":"ok"}"""
MessageRole.SYSTEM -> "filler $index"
else -> "continue"
},
timestamp = index.toLong(),
toolName = if (role == MessageRole.TOOL && index > 300) "terminal" else null,
),
)
}
val storedIds = fakeRepo.dao.idsForSession("session-456")
stubSession456Rests(success = false)
val (viewModel, _) = createViewModelWithSession()
viewModel.switchSession("session-456")
advanceUntilIdle()
assertEquals(150, viewModel.uiState.value.messages.size)
viewModel.loadOlderMessages()
advanceUntilIdle()
for (index in roles.indices) {
val retained =
viewModel.uiState.value.messages
.single { it.id == "uuid-$index" }
assertEquals("rest-session-456-${20 + index}", retained.restId)
}
assertEquals(297, viewModel.uiState.value.messages.size)
viewModel.loadOlderMessages()
advanceUntilIdle()
val messages = viewModel.uiState.value.messages
assertEquals(300, messages.size)
for (index in roles.indices) {
assertEquals(2, messages.count { it.role == roles[index] })
assertTrue(messages.any { it.id == "rest-session-456-${10 + index}" })
assertTrue(messages.any { it.id == "uuid-$index" })
}
assertEquals("terminal", messages.single { it.id == "uuid-2" }.toolName)
assertEquals(storedIds, fakeRepo.dao.idsForSession("session-456"))
assertFalse(viewModel.uiState.value.hasOlderMessages)
}
@Test
fun paging_reusedToolPersistsCanonicalKeyWithoutReplacingLiveKey() =
runTest {
fakeRepo.dao.addMessageDirect(
com.m57.hermescontrol.data.local.ChatMessageEntity(
id = "uuid-tool",
sessionId = "session-456",
role = "TOOL",
content = """{"output":"ok"}""",
timestamp = 1L,
toolName = "terminal",
),
)
val api = ApiClient.hermesApi
val response = pagingResponse(42..42).body()!!
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} returns
retrofit2.Response.success(
response.copy(
messages =
response.messages.map {
it.copy(
role = "tool",
content = JsonPrimitive("""{"output":"ok"}"""),
)
},
),
)
val (viewModel, _) = createViewModelWithSession()
viewModel.switchSession("session-456")
advanceUntilIdle()
val message =
viewModel.uiState.value.messages
.single()
assertEquals("uuid-tool", message.id)
assertEquals("rest-session-456-42", message.restId)
assertEquals("terminal", message.toolName)
assertEquals(setOf("uuid-tool", "rest-session-456-42"), fakeRepo.dao.idsForSession("session-456"))
}
@Test
fun paging_emptyServerPageDoesNotHideOlderOfflineHistory() =
runTest {
val api = ApiClient.hermesApi
val expectedIds = seedPagingCache(350)
val (viewModel, _) = createViewModelWithSession()
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} returns pagingResponse(IntRange.EMPTY)
viewModel.switchSession("session-456")
advanceUntilIdle()
assertEquals(150, viewModel.uiState.value.messages.size)
assertTrue(viewModel.uiState.value.hasOlderMessages)
viewModel.loadOlderMessages()
advanceUntilIdle()
assertEquals(300, viewModel.uiState.value.messages.size)
assertEquals(expectedIds.toSet(), fakeRepo.dao.idsForSession("session-456"))
}
@Test
fun paging_cacheFirst_serverHydrationDoesNotInflateInitialWindow() =
runTest {
val api = ApiClient.hermesApi
seedPagingCache(10_000)
val response =
CompletableDeferred<retrofit2.Response<com.m57.hermescontrol.data.model.SessionMessagesResponse>>()
val (viewModel, _) = createViewModelWithSession()
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} coAnswers { response.await() }
viewModel.switchSession("session-456")
runCurrent()
val cacheSize = viewModel.uiState.value.messages.size
response.complete(pagingResponse(1..150))
advanceUntilIdle()
assertEquals(150, cacheSize)
assertEquals(300, viewModel.uiState.value.messages.size)
assertFalse(viewModel.uiState.value.isLoading)
assertTrue(viewModel.uiState.value.hasOlderMessages)
assertEquals(0, fakeRepo.dao.fullSessionReads)
}
@Test
fun paging_serverFirst_cacheCursorStillAllowsOfflineHistory() =
runTest {
val api = ApiClient.hermesApi
seedPagingCache(350)
val cacheRead = CompletableDeferred<Unit>()
fakeRepo.dao.beforeRead = { cacheRead.await() }
val (viewModel, _) = createViewModelWithSession()
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} returns pagingResponse(IntRange.EMPTY)
viewModel.switchSession("session-456")
runCurrent()
cacheRead.complete(Unit)
advanceUntilIdle()
assertEquals(150, viewModel.uiState.value.messages.size)
assertTrue(viewModel.uiState.value.hasOlderMessages)
viewModel.loadOlderMessages()
advanceUntilIdle()
assertEquals(300, viewModel.uiState.value.messages.size)
assertFalse(viewModel.uiState.value.isLoadingOlder)
}
@Test
fun paging_pendingOlderPageIsSuppressedAndFailureAllowsRetry() =
runTest {
val api = ApiClient.hermesApi
val (viewModel, _) = createViewModelWithSession()
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} returns pagingResponse(151..300)
viewModel.switchSession("session-456")
advanceUntilIdle()
val pending =
CompletableDeferred<retrofit2.Response<com.m57.hermescontrol.data.model.SessionMessagesResponse>>()
coEvery {
api.getSessionMessages("session-456", 150, 150, "latest", any())
} coAnswers { pending.await() }
viewModel.loadOlderMessages()
runCurrent()
repeat(5) { viewModel.loadOlderMessages() }
runCurrent()
coVerify(exactly = 1) {
api.getSessionMessages("session-456", 150, 150, "latest", any())
}
assertTrue(viewModel.uiState.value.isLoadingOlder)
pending.complete(retrofit2.Response.error(500, okhttp3.ResponseBody.create(null, "test failure")))
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoadingOlder)
assertTrue(viewModel.uiState.value.hasOlderMessages)
coEvery {
api.getSessionMessages("session-456", 150, 150, "latest", any())
} returns pagingResponse(1..150, offset = 150)
viewModel.loadOlderMessages()
advanceUntilIdle()
assertEquals(300, viewModel.uiState.value.messages.size)
assertFalse(viewModel.uiState.value.isLoadingOlder)
}
@Test
fun paging_oldSessionResponseCannotReplaceNewSession() =
runTest {
val api = ApiClient.hermesApi
val (viewModel, _) = createViewModelWithSession()
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} returns pagingResponse(151..300)
coEvery {
api.getSessionMessages("session-other", any(), any(), any(), any())
} returns pagingResponse(900..900)
viewModel.switchSession("session-456")
advanceUntilIdle()
val pending =
CompletableDeferred<retrofit2.Response<com.m57.hermescontrol.data.model.SessionMessagesResponse>>()
coEvery {
api.getSessionMessages("session-456", 150, 150, "latest", any())
} coAnswers { pending.await() }
viewModel.loadOlderMessages()
runCurrent()
viewModel.switchSession("session-other")
runCurrent()
pending.complete(pagingResponse(1..150, offset = 150))
advanceUntilIdle()
assertEquals("session-other", viewModel.uiState.value.currentSessionId)
assertEquals(
listOf("rest-session-other-900"),
viewModel.uiState.value.messages
.map { it.id },
)
assertFalse(viewModel.uiState.value.isLoadingOlder)
}
/** Holds CPU continuations without sleeping or starting a real thread. */
private class QueuedHistoryDispatcher : kotlinx.coroutines.CoroutineDispatcher() {
private val queue = ArrayDeque<Runnable>()
val pending: Boolean get() = queue.isNotEmpty()
override fun dispatch(
context: kotlin.coroutines.CoroutineContext,
block: Runnable,
) {
queue.addLast(block)
}
fun runNext() = queue.removeFirst().run()
}
private fun TestScope.drainHistory(dispatcher: QueuedHistoryDispatcher) {
repeat(100) {
runCurrent()
if (!dispatcher.pending) return
dispatcher.runNext()
}
error("History did not settle after 100 CPU continuations")
}
@Test
fun paging_liveMessageArrivingBeforeComputedSnapshotCommitSurvives() =
runTest {
val api = ApiClient.hermesApi
val cpu = QueuedHistoryDispatcher()
val (viewModel, _) = createViewModelWithSession(historyDispatcher = cpu)
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} returns pagingResponse(1..150)
viewModel.switchSession("session-456")
runCurrent()
// Cache query runs first; hydration has already captured an empty snapshot.
cpu.runNext()
runCurrent()
mockEventsFlow.emit(WsEvent.MessageComplete("Live during merge", "session-456"))
cpu.runNext()
// The queued WS event runs before the computed result can commit on Main.
runCurrent()
drainHistory(cpu)
val messages = viewModel.uiState.value.messages
assertEquals(151, messages.size)
assertEquals(1, messages.count { it.content == "Live during merge" })
assertFalse(viewModel.uiState.value.isLoading)
}
@Test
fun paging_sessionSwitchDiscardsQueuedCpuResult() =
runTest {
val api = ApiClient.hermesApi
val cpu = QueuedHistoryDispatcher()
val (viewModel, _) = createViewModelWithSession(historyDispatcher = cpu)
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} returns pagingResponse(1..150)
coEvery {
api.getSessionMessages("session-other", any(), any(), any(), any())
} returns pagingResponse(900..900)
viewModel.switchSession("session-456")
runCurrent()
cpu.runNext()
runCurrent()
cpu.runNext()
viewModel.switchSession("session-other")
drainHistory(cpu)
assertEquals("session-other", viewModel.uiState.value.currentSessionId)
assertEquals(
listOf("rest-session-other-900"),
viewModel.uiState.value.messages
.map { it.id },
)
assertFalse(viewModel.uiState.value.isLoading)
}
@Test
fun paging_reconnectInvalidatesPendingOlderRequestAndItsCursor() =
runTest {
val api = ApiClient.hermesApi
val (viewModel, _) = createViewModelWithSession()
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} returns pagingResponse(301..450)
viewModel.switchSession("session-456")
advanceUntilIdle()
val oldResumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second
mockEventsFlow.emit(WsEvent.RpcResult(oldResumeId, mapOf("session_id" to "runtime-456")))
advanceUntilIdle()
assertTrue(viewModel.uiState.value.isSessionReady)
val pending =
CompletableDeferred<retrofit2.Response<com.m57.hermescontrol.data.model.SessionMessagesResponse>>()
coEvery {
api.getSessionMessages("session-456", 150, 150, "latest", any())
} coAnswers { pending.await() }
viewModel.loadOlderMessages()
runCurrent()
coEvery {
api.getSessionMessages("session-456", 150, 0, "latest", any())
} returns pagingResponse(501..650)
mockConnectionStatus.value = ConnectionStatus.RECONNECTING
runCurrent()
assertFalse(viewModel.uiState.value.isSessionReady)
assertFalse(viewModel.uiState.value.isLoadingOlder)
assertFalse(viewModel.sendMessage("preserved draft"))
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
runCurrent()
pending.complete(pagingResponse(1..150, offset = 150))
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoadingOlder)
assertFalse(
viewModel.uiState.value.messages
.any { it.id == "rest-session-456-1" },
)
mockEventsFlow.emit(WsEvent.RpcResult(oldResumeId, mapOf("session_id" to "stale-runtime")))
runCurrent()
assertFalse(viewModel.uiState.value.isSessionReady)
val freshResumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second
mockEventsFlow.emit(WsEvent.RpcResult(freshResumeId, mapOf("session_id" to "fresh-runtime")))
advanceUntilIdle()
assertTrue(viewModel.uiState.value.isSessionReady)
coEvery {
api.getSessionMessages("session-456", 150, 150, "latest", any())
} returns pagingResponse(351..500, offset = 150)
viewModel.loadOlderMessages()
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.messages
.any { it.id == "rest-session-456-500" },
)
coVerify(exactly = 0) {
api.getSessionMessages("session-456", 150, 300, "latest", any())
}
}
@Test
fun paging_offlineCacheRemainsReadableAndFreshResumeRestoresSendReadiness() =
runTest {
val ids = seedPagingCache(350)
stubSession456Rests(success = false)
val (viewModel, _) = createViewModelWithSession()
viewModel.switchSession("session-456")
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isSessionReady)
assertFalse(viewModel.sendMessage("draft"))
mockConnectionStatus.value = ConnectionStatus.NO_NETWORK
runCurrent()
viewModel.loadOlderMessages()
advanceUntilIdle()
assertEquals(
ids.takeLast(300),
viewModel.uiState.value.messages
.map { it.id },
)
assertFalse(viewModel.uiState.value.isLoadingOlder)
val api = ApiClient.hermesApi
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} returns pagingResponse(IntRange.EMPTY)
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isSessionReady)
val resumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second
mockEventsFlow.emit(WsEvent.RpcResult(resumeId, mapOf("session_id" to "runtime-456")))
advanceUntilIdle()
assertTrue(viewModel.uiState.value.isSessionReady)
assertTrue(viewModel.sendMessage("draft"))
advanceUntilIdle()
assertEquals(
1,
viewModel.uiState.value.messages
.count { it.content == "draft" },
)
}
@Test
fun paging_cacheAndServerCursorsAdvanceIndependently() =
runTest {
val api = ApiClient.hermesApi
val cachedIds = seedPagingCache(350)
val (viewModel, _) = createViewModelWithSession()
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} returns pagingResponse(151..300)
viewModel.switchSession("session-456")
advanceUntilIdle()
repeat(2) {
viewModel.loadOlderMessages()
advanceUntilIdle()
}
coVerify(exactly = 1) {
api.getSessionMessages("session-456", any(), any(), any(), any())
}
assertEquals(
cachedIds.toSet() + (151..300).map { "rest-session-456-$it" },
viewModel.uiState.value.messages
.map { it.id }
.toSet(),
)
coEvery {
api.getSessionMessages("session-456", 150, 150, "latest", any())
} returns pagingResponse(1..150, offset = 150)
viewModel.loadOlderMessages()
advanceUntilIdle()
// The remaining cache page contains only the latest REST rows already on screen.
// This same action must reach the independent server cursor, not stop on those echoes.
coVerify(exactly = 1) {
api.getSessionMessages("session-456", 150, 150, "latest", any())
}
val expectedIds = cachedIds.toSet() + (1..300).map { "rest-session-456-$it" }
assertEquals(650, viewModel.uiState.value.messages.size)
assertEquals(
expectedIds,
viewModel.uiState.value.messages
.map { it.id }
.toSet(),
)
assertEquals(expectedIds, fakeRepo.dao.idsForSession("session-456"))
assertFalse(viewModel.uiState.value.isLoadingOlder)
assertEquals(0, fakeRepo.dao.fullSessionReads)
assertTrue(fakeRepo.dao.pageLimits.all { it == 151 })
}
@Test
fun paging_latestGrowthBetweenPagesAndSyncNeverSkipsOlderRows() =
runTest {
val api = ApiClient.hermesApi
val (viewModel, _) = createViewModelWithSession()
var total = 450
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} coAnswers {
val offset = arg<Int>(2)
val end = (total - offset).coerceAtLeast(0)
val start = (end - 150).coerceAtLeast(0) + 1
pagingResponse(start..end, offset)
}
viewModel.switchSession("session-456")
advanceUntilIdle()
viewModel.loadOlderMessages()
advanceUntilIdle()
total = 460
viewModel.syncCurrentSession()
advanceUntilIdle()
repeat(2) {
viewModel.loadOlderMessages()
advanceUntilIdle()
}
assertEquals(
(1..460).map { "rest-session-456-$it" }.toSet(),
viewModel.uiState.value.messages
.map { it.id }
.toSet(),
)
assertFalse(viewModel.uiState.value.hasOlderMessages)
}
@Test
fun paging_rawHiddenRowsStillAdvanceServerCursor() =
runTest {
val api = ApiClient.hermesApi
val (viewModel, _) = createViewModelWithSession()
val response = pagingResponse(1..150).body()!!
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} returns
retrofit2.Response.success(
response.copy(messages = response.messages.map { it.copy(content = JsonPrimitive("")) }),
)
viewModel.switchSession("session-456")
advanceUntilIdle()
assertTrue(viewModel.uiState.value.hasOlderMessages)
coEvery {
api.getSessionMessages("session-456", 150, 150, "latest", any())
} returns pagingResponse(IntRange.EMPTY, offset = 150)
viewModel.loadOlderMessages()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.hasOlderMessages)
coVerify(exactly = 1) {
api.getSessionMessages("session-456", 150, 150, "latest", any())
}
}
@Test
fun paging_cacheReadExceptionSettlesAndAllowsRetry() =
runTest {
val ids = seedPagingCache(350)
stubSession456Rests(success = false)
fakeRepo.dao.beforeRead = { error("Synthetic Room read failure") }
val (viewModel, _) = createViewModelWithSession()
viewModel.switchSession("session-456")
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertTrue(viewModel.uiState.value.hasOlderMessages)
fakeRepo.dao.beforeRead = {}
viewModel.loadOlderMessages()
advanceUntilIdle()
assertEquals(
ids.takeLast(150),
viewModel.uiState.value.messages
.map { it.id },
)
assertFalse(viewModel.uiState.value.isLoadingOlder)
}
@Test
fun paging_mapperExceptionSettlesAndRetriesSameServerOffset() =
runTest {
val api = ApiClient.hermesApi
val (viewModel, _) = createViewModelWithSession()
coEvery {
api.getSessionMessages("session-456", any(), any(), any(), any())
} returns pagingResponse(151..300)
viewModel.switchSession("session-456")
advanceUntilIdle()
val invalid = pagingResponse(1..150, 150).body()!!
coEvery {
api.getSessionMessages("session-456", 150, 150, "latest", any())
} returns retrofit2.Response.success(invalid.copy(messages = invalid.messages.map { it.copy(id = null) }))
viewModel.loadOlderMessages()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoadingOlder)
assertTrue(viewModel.uiState.value.hasOlderMessages)
coEvery {
api.getSessionMessages("session-456", 150, 150, "latest", any())
} returns pagingResponse(1..150, 150)
viewModel.loadOlderMessages()
advanceUntilIdle()
assertEquals(300, viewModel.uiState.value.messages.size)
}
private suspend fun TestScope.assertOverlappingHistoryPageKeepsOrder(equalTimestamps: Boolean) {
val api = ApiClient.hermesApi
val (viewModel, _) = createViewModelWithSession()
(1..350).forEach { index ->
fakeRepo.dao.addMessageDirect(
com.m57.hermescontrol.data.local.ChatMessageEntity(
id = "rest-session-456-$index",
sessionId = "session-456",
role = "ASSISTANT",
content = "Server message $index",
timestamp = if (equalTimestamps) 1_000L else index * 1_000L,
),
)
}
coEvery { api.getSessionMessages("session-456", any(), any(), any(), any()) } coAnswers {
val offset = arg<Int>(2)
val end = 350 - offset
val response = pagingResponse((end - 149).coerceAtLeast(1)..end, offset).body()!!
retrofit2.Response.success(
if (equalTimestamps) {
response.copy(messages = response.messages.map { it.copy(timestamp = JsonPrimitive(1)) })
} else {
response
},
)
}
viewModel.switchSession("session-456")
advanceUntilIdle()
repeat(2) {
viewModel.loadOlderMessages()
advanceUntilIdle()
}
val before =
viewModel.uiState.value.messages
.map { it.id }
assertEquals((1..350).map { "rest-session-456-$it" }.toSet(), before.toSet())
assertEquals(350, before.size)
coVerify(exactly = 1) { api.getSessionMessages("session-456", any(), any(), any(), any()) }
// Local history is exhausted. Both subsequent server pages overlap it completely.
repeat(2) {
viewModel.loadOlderMessages()
advanceUntilIdle()
assertEquals(
before,
viewModel.uiState.value.messages
.map { it.id },
)
}
assertFalse(viewModel.uiState.value.hasOlderMessages)
coVerify(exactly = 1) { api.getSessionMessages("session-456", 150, 150, "latest", any()) }
coVerify(exactly = 1) { api.getSessionMessages("session-456", 150, 300, "latest", any()) }
}
@Test
fun paging_cacheExhaustedOverlappingServerPageKeepsExistingOrder() =
runTest { assertOverlappingHistoryPageKeepsOrder(equalTimestamps = false) }
@Test
fun paging_cacheExhaustedOverlapWithEqualTimestampsKeepsExistingOrder() =
runTest { assertOverlappingHistoryPageKeepsOrder(equalTimestamps = true) }
// ── Attachment open (issue #724) ─────────────────────────────────────
@Test
@@ -0,0 +1,39 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.fullbleed.fullBleedItemKeys
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns
import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex
import org.junit.Assert.assertEquals
import org.junit.Test
class FullBleedItemKeysTest {
@Test
fun keysIncludeReasoningToolsAndSystemButSkipEmptyProse() {
val turns =
groupIntoTurns(
listOf(
ChatMessage("u", MessageRole.USER, "question"),
ChatMessage("empty", MessageRole.ASSISTANT, ""),
ChatMessage("t", MessageRole.TOOL, "output"),
ChatMessage("a", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"),
ChatMessage("s", MessageRole.SYSTEM, "event"),
),
)
assertEquals(listOf("user-u", "reasoning-a", "tool-t", "prose-a", "sys-s"), fullBleedItemKeys(turns))
assertEquals(mapOf("u" to 0, "a" to 3), messageIdToLazyIndex(turns))
assertEquals(mapOf("u" to 2, "a" to 5), messageIdToLazyIndex(turns, leadingItems = 2))
}
@Test
fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() {
val tool = ChatMessage("live-tool", MessageRole.TOOL, "output")
val older =
(0..149).map {
ChatMessage("old-$it", MessageRole.ASSISTANT, "answer", reasoningText = "thinking")
}
assertEquals(listOf("tool-live-tool"), fullBleedItemKeys(groupIntoTurns(listOf(tool))))
val keys = fullBleedItemKeys(groupIntoTurns(older + tool))
assertEquals("reasoning-old-0", keys.first())
assertEquals(151, keys.indexOf("tool-live-tool"))
}
}
@@ -104,6 +104,172 @@ class ReadNotificationReviewRegressionTest {
assertEquals(listOf("old-completion", "new-completion"), mapped.map { it.completionId })
}
@Test
fun pagingMergePreservesBothIdentitiesAndDoesNotConsumeOlderRepeatedReply() {
val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp")
val newest = listOf(SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")))
val mapped = mapServerMessages("session", newest, 0, true, listOf(live))
val hydrated = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true)
assertEquals("ws", hydrated.single().id)
assertEquals("rest-session-200", hydrated.single().restId)
assertEquals("comp", hydrated.single().completionId)
val older = listOf(SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")))
val page = mapServerMessages("session", older, 150, true, hydrated, isPagingOlder = true)
val merged = mergeTranscriptWithLive(page, hydrated, chronological = false, preserveLiveIds = true)
assertEquals(listOf("rest-session-100", "ws"), merged.map { it.id })
assertEquals(listOf(null, "comp"), merged.map { it.completionId })
assertEquals("rest-session-200", merged.last().restId)
// A later refresh of ONLY the old row must not reassign the confirmed UUID.
assertNull(mapServerMessages("session", older, 0, true, hydrated).single().completionId)
assertEquals("comp", mapServerMessages("session", newest, 0, true, hydrated).single().completionId)
}
@Test
fun olderPageAndCacheMergeCannotClaimUnconfirmedNotificationByText() {
val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp")
val older = listOf(SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")))
val page = mapServerMessages("session", older, 150, true, listOf(live), isPagingOlder = true)
val merged = mergeTranscriptWithLive(page, listOf(live), chronological = false, preserveLiveIds = true)
assertEquals(2, merged.size)
assertNull(merged.single { it.id == "rest-session-100" }.completionId)
assertNull(merged.single { it.id == "ws" }.restId)
assertEquals(2, mergeCachedTranscriptPage(page, listOf(live)).size)
}
@Test
fun repeatedCompletionMatchesStayOnTheSameLiveKeysAfterMerge() {
val live =
listOf(
ChatMessage(id = "old-ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "old"),
ChatMessage(id = "new-ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "new"),
)
val page =
mapServerMessages(
"session",
listOf(SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done"))),
0,
true,
live,
)
val merged = mergeTranscriptWithLive(page, live, preserveLiveIds = true)
assertEquals("rest-session-200", merged.single { it.id == "new-ws" }.restId)
assertNull(merged.single { it.id == "old-ws" }.restId)
assertEquals("new", merged.single { it.id == "new-ws" }.completionId)
}
@Test
fun durableTargetWinsOverRepeatedLiveTextEvenWithLegacyPositions() {
ReplyNotificationTracker.setTargetForTest(
ReplyNotificationTarget("default", "session", "comp", 1L, serverMessageId = 100),
)
val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp")
val rows =
listOf(
SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")),
SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")),
)
for (latest in listOf(true, false)) {
val mapped = mapServerMessages("session", rows, 10, latest, listOf(live), context = context)
assertEquals(listOf("comp", null), mapped.map { it.completionId })
val merged = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true)
assertEquals(if (latest) "rest-session-100" else "rest-session-10", merged.single { it.id == "ws" }.restId)
assertNull(mapServerMessages("session", rows.takeLast(1), 11, latest, listOf(live)).single().completionId)
}
}
@Test
fun exactCacheAndRestOverlapsRetainCompletionWhenPreferredCopyHasNone() {
val live =
ChatMessage(
id = "ws",
role = MessageRole.ASSISTANT,
content = "Done",
restId = "rest-session-200",
completionId = "comp",
)
val rest = live.copy(id = "rest-session-200", restId = null, completionId = null)
assertEquals("comp", dedupeCachedMessages(listOf(live, rest)).single().completionId)
assertEquals("comp", mergeCachedTranscriptPage(listOf(rest), listOf(live)).single().completionId)
assertEquals("comp", mergeTranscriptWithLive(listOf(rest), listOf(live)).single().completionId)
val restored = rest.copy(completionId = "comp").toEntity("session").toUiModel()
val richWithoutCompletion = live.copy(completionId = null)
val merged = mergeCachedTranscriptPage(listOf(restored), listOf(richWithoutCompletion)).single()
assertEquals("ws", merged.id)
assertEquals("rest-session-200", merged.restId)
assertEquals("comp", merged.completionId)
}
@Test
fun durableHydrationFoldsMixedCacheEchoIntoRichLiveReply() {
ReplyNotificationTracker.setTargetForTest(
ReplyNotificationTarget("default", "session", "comp", 1L, serverMessageId = 200),
)
val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp")
val echo = live.copy(id = "rest-session-200", completionId = null)
val current = listOf(echo, live)
val mapped =
mapServerMessages(
"session",
listOf(SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done"))),
0,
true,
current,
)
val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true).single()
assertEquals("ws", merged.id)
assertEquals("rest-session-200", merged.restId)
assertEquals("comp", merged.completionId)
}
@Test
fun cachedNewestDuplicateWithoutDurableIdKeepsCompletionOnNewestLiveReply() {
// #129 review: a metadata-free cached echo must not redirect the newest WS completion.
ReplyNotificationTracker.setTargetForTest(
ReplyNotificationTarget("default", "session", "comp", 1L, serverMessageId = null),
)
val cachedNewest =
ChatMessage(
id = "rest-session-200",
role = MessageRole.ASSISTANT,
content = "Done",
completionId = null,
)
val live =
ChatMessage(
id = "ws",
role = MessageRole.ASSISTANT,
content = "Done",
completionId = "comp",
restId = null,
)
val current = listOf(cachedNewest, live)
val history =
listOf(
SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")),
SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")),
)
val mapped =
mapServerMessages(
sessionId = "session",
messages = history,
offset = 0,
latestPaging = true,
liveMessages = current,
isPagingOlder = false,
context = context,
)
val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true)
assertNull(mapped.single { it.id == "rest-session-100" }.completionId)
assertEquals("comp", mapped.single { it.id == "rest-session-200" }.completionId)
assertEquals(2, merged.size)
assertEquals(setOf("rest-session-100", "ws"), merged.map { it.id }.toSet())
assertEquals(2, merged.count { it.content == "Done" })
assertNull(merged.single { it.id == "rest-session-100" }.completionId)
assertEquals("rest-session-200", merged.single { it.id == "ws" }.restId)
assertEquals("comp", merged.single { it.id == "ws" }.completionId)
}
@Test
fun viewingOldCachedTextMustNotClearNewReplyNotification() {
postTarget("Done")
@@ -383,7 +549,11 @@ class ReadNotificationReviewRegressionTest {
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("The result MEDIA:/opt/pic.png")),
)
val mapped = mapServerMessages("session", history, 0, true, live, isPagingOlder = false)
assertEquals("comp-reasoning-media", mapped.single().completionId)
assertEquals(listOf("rest-session-1", "rest-session-2"), mapped.map { it.canonicalRestId })
assertEquals("thinking step", mapped.first().reasoningText)
assertEquals("", mapped.first().content)
assertNull(mapped.first().completionId)
assertEquals("comp-reasoning-media", mapped.last().completionId)
}
@Test
@@ -0,0 +1,71 @@
package com.m57.hermescontrol.ui.chat.components
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatHistoryPrefetchTest {
@Test
fun shortUpwardDragCanReachMovementThresholdDuringFling() {
val policy = ChatHistoryPrefetch()
assertFalse(policy.onScroll(30, 5f, userInput = true, canLoad = true))
assertTrue(policy.onScroll(25, 30f, userInput = false, canLoad = true))
assertFalse(policy.onScroll(10, 30f, userInput = false, canLoad = true))
}
@Test
fun upwardFlingCanReachPrefetchThresholdAfterDragEnds() {
val policy = ChatHistoryPrefetch()
assertFalse(policy.onScroll(70, 30f, userInput = true, canLoad = true))
assertFalse(policy.onScroll(40, 40f, userInput = false, canLoad = true))
assertTrue(policy.onScroll(25, 40f, userInput = false, canLoad = true))
assertFalse(policy.onScroll(10, 40f, userInput = false, canLoad = true))
policy.endGesture()
assertFalse(policy.onScroll(5, 40f, userInput = false, canLoad = true))
}
@Test
fun reversingDirectionDisarmsPreviousUpwardIntent() {
val policy = ChatHistoryPrefetch()
assertFalse(policy.onScroll(70, 30f, userInput = true, canLoad = true))
assertFalse(policy.onScroll(50, -40f, userInput = true, canLoad = true))
assertFalse(policy.onScroll(25, 40f, userInput = false, canLoad = true))
}
@Test
fun initialTopAndProgrammaticMovementDoNotFetch() {
val policy = ChatHistoryPrefetch()
assertFalse(policy.onScroll(0, 0f, userInput = true, canLoad = true))
assertFalse(policy.onScroll(0, 100f, userInput = false, canLoad = true))
assertFalse(policy.onScroll(0, -100f, userInput = true, canLoad = true))
}
@Test
fun prefetchStartsTwentyFiveRowsBeforeTopAfterMeaningfulMovement() {
val policy = ChatHistoryPrefetch()
assertFalse(policy.onScroll(30, 30f, userInput = true, canLoad = true))
assertTrue(policy.onScroll(25, 1f, userInput = true, canLoad = true))
}
@Test
fun initialTopNeedsDeliberateGestureAndRequestsOnlyOnce() {
val policy = ChatHistoryPrefetch()
assertFalse(policy.onScroll(0, 10f, userInput = true, canLoad = true))
assertTrue(policy.onScroll(0, 15f, userInput = true, canLoad = true))
repeat(100) {
assertFalse(policy.onScroll(0, 50f, userInput = true, canLoad = true))
}
policy.endGesture()
assertTrue(policy.onScroll(0, 25f, userInput = true, canLoad = true))
}
@Test
fun loadingAndExhaustionSuppressRequestsAndNextGestureCanRetry() {
val policy = ChatHistoryPrefetch()
assertFalse(policy.onScroll(20, 50f, userInput = true, canLoad = false))
policy.endGesture()
assertTrue(policy.onScroll(20, 25f, userInput = true, canLoad = true))
policy.endGesture()
assertTrue(policy.onScroll(20, 25f, userInput = true, canLoad = true))
}
}
@@ -2,29 +2,80 @@ package com.m57.hermescontrol.ui.chat.fakes
import com.m57.hermescontrol.data.local.ChatMessageDao
import com.m57.hermescontrol.data.local.ChatMessageEntity
import com.m57.hermescontrol.data.local.canonicalMessageOrder
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
/**
* In-memory [ChatMessageDao] for use in tests.
* Uses [ConcurrentHashMap] for thread safety — no manual synchronization needed.
* Paging mirrors the DAO's numeric cursor; writes use its default order allocation methods.
*/
class FakeChatMessageDao : ChatMessageDao {
private val messages: ConcurrentMap<String, ChatMessageEntity> = ConcurrentHashMap()
private val ordering = compareBy<ChatMessageEntity> { it.sortGroup }.thenBy { it.sortOrder }.thenBy { it.id }
private var insertionSequence = 0L
var fullSessionReads = 0
private set
var beforeRead: suspend () -> Unit = {}
override suspend fun sessionExists(sessionId: String): Boolean = messages.values.any { it.sessionId == sessionId }
override suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity> =
messages.values
override suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity> {
fullSessionReads++
beforeRead()
return messages.values
.filter { it.sessionId == sessionId }
.sortedBy { it.timestamp }
override suspend fun upsert(message: ChatMessageEntity) {
messages[message.id] = message
.sortedWith(ordering)
}
override suspend fun upsertAll(messageList: List<ChatMessageEntity>) {
messageList.forEach { messages[it.id] = it }
val pageLimits = mutableListOf<Int>()
override suspend fun getLatestMessagePage(
sessionId: String,
limit: Int,
): List<ChatMessageEntity> {
pageLimits += limit
beforeRead()
return messages.values
.filter { it.sessionId == sessionId }
.sortedWith(ordering.reversed())
.take(limit)
}
override suspend fun getMessagePage(
sessionId: String,
beforeGroup: Int,
beforeOrder: Long,
beforeId: String,
limit: Int,
): List<ChatMessageEntity> {
pageLimits += limit
beforeRead()
return messages.values
.filter {
it.sessionId == sessionId &&
(
it.sortGroup < beforeGroup ||
(
it.sortGroup == beforeGroup &&
(it.sortOrder < beforeOrder || (it.sortOrder == beforeOrder && it.id < beforeId))
)
)
}.sortedWith(ordering.reversed())
.take(limit)
}
override suspend fun getMessage(id: String): ChatMessageEntity? = messages[id]
override suspend fun nextLocalOrder(): Long = insertionSequence + 1
override suspend fun writeMessage(message: ChatMessageEntity) {
if (message.id !in messages) insertionSequence++
messages[message.id] = message
}
override suspend fun deleteMessagesForSession(sessionId: String) {
@@ -33,14 +84,25 @@ class FakeChatMessageDao : ChatMessageDao {
/** Direct access for test setup — bypasses the suspend modifier. */
fun addMessageDirect(message: ChatMessageEntity) {
messages[message.id] = message
val existing = messages[message.id]
if (existing == null) insertionSequence++
val order = canonicalMessageOrder(message.restId ?: message.id, message.sessionId)
messages[message.id] =
message.copy(
sortGroup = if (order != null) 0 else 1,
sortOrder = order ?: existing?.sortOrder ?: insertionSequence,
)
}
/** Reset all stored messages. */
fun clear() {
messages.clear()
insertionSequence = 0L
}
fun idsForSession(sessionId: String): Set<String> =
messages.values.filter { it.sessionId == sessionId }.mapTo(mutableSetOf()) { it.id }
/** Returns the number of stored messages. */
fun count(): Int = messages.size
}