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:
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"))
|
||||
}
|
||||
}
|
||||
+171
-1
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user