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,379 @@
package com.m57.hermescontrol.data.local
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.execSQL
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.MessageRole
import kotlinx.coroutines.runBlocking
import net.zetetic.database.sqlcipher.driver.SQLCipherDriver
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.UUID
/** Executes the generated Room DAO against SQLCipher, rather than reproducing its SQL in a fake. */
@RunWith(AndroidJUnit4::class)
class ChatMessagePagingDeviceTest {
private lateinit var context: Context
private lateinit var database: HermesDatabase
private lateinit var repository: ChatPersistenceRepository
private val databaseName = "paging-regression-${UUID.randomUUID()}.db"
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
System.loadLibrary("sqlcipher")
openDatabase()
}
private fun openDatabase() {
database =
Room
.databaseBuilder(context, HermesDatabase::class.java, databaseName)
.setDriver(driver())
.addMigrations(HermesDatabase.MIGRATION_8_9)
.build()
repository = ChatPersistenceRepository(database.chatMessageDao())
}
@After
fun tearDown() {
database.close()
context.deleteDatabase(databaseName)
}
@Test
fun unpaddedRestIdsSelectNumericLatestPageAndWalkEveryRowExactlyOnce() =
runBlocking {
val rows = (1..350).map { restRow(it, timestamp = 10L) }
repository.persistMessages(rows, "session")
repository.persistMessage(restRow(999, timestamp = 10L).copy(id = "rest-other-999"), "other")
val latest = repository.loadPage("session", null, 150)
assertEquals(rows.takeLast(150).map { it.id }, latest.messages.map { it.id })
repository.persistMessage(restRow(351, timestamp = 1L), "session")
val middle = repository.loadPage("session", latest.cursor, 150)
val oldest = repository.loadPage("session", middle.cursor, 150)
assertEquals(rows.map { it.id }, (oldest.messages + middle.messages + latest.messages).map { it.id })
assertEquals(true, latest.hasOlder)
assertEquals(true, middle.hasOlder)
assertEquals(false, oldest.hasOlder)
assertEquals(
"rest-session-351",
repository
.loadPage("session", null, 1)
.messages
.single()
.id,
)
assertEquals(
"rest-other-999",
repository
.loadPage("other", null, 1)
.messages
.single()
.id,
)
}
@Test
fun nonMonotonicTimestampsKeepServerOrderInBothPageArrivalOrdersAfterReopen() =
runBlocking {
val rows =
listOf(9, 10, 100, 200).mapIndexed { index, id ->
restRow(id, 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") }
database.close()
openDatabase()
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(false, older.hasOlder)
}
}
@Test
fun uuidOnlyRowsKeepInsertionOrderAfterUpdateAppendAndReopen() =
runBlocking {
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")
database.close()
openDatabase()
val latest = repository.loadPage("local", null, 2)
repository.persistMessage(
rows.last().copy(id = "bbbbbbbb-0000-4000-8000-000000000000", content = "new", 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(
"new",
repository
.loadPage("local", null, 1)
.messages
.single()
.content,
)
}
@Test
fun migration8to9ValidatesRealSchemaAndPreservesRowsMetadataAndPaging() =
runBlocking {
val canonical =
listOf(9, 10, 100, 200).mapIndexed { index, id ->
restRow(id, listOf(900L, 100L, 800L, 200L)[index]).toEntity("session").copy(
reasoningText = "trace-$id",
toolName = "terminal",
toolCallId = "call-$id",
toolStatus = "COMPLETED",
isStreaming = true,
displayKind = "marker-$id",
tokenCount = id,
tps = 2.5,
completionId = "completion-$id",
)
}
val local =
listOf("ffffffff", "00000000", "aaaaaaaa").mapIndexed { index, prefix ->
ChatMessageEntity(
id = "$prefix-0000-4000-8000-000000000000",
sessionId = "local",
role = "USER",
content = "local-$index",
timestamp = 100L - index,
)
}
val malformed =
listOf(
"rest-session-",
"rest-session-10x",
"rest-other-50",
"rest-session-9223372036854775808",
).map {
ChatMessageEntity(
id = it,
sessionId = "session",
role = "USER",
content = it,
timestamp = 1L,
)
}
// REST pages can be inserted backwards; UUID order is physical insertion, not timestamp or text.
val seeded = canonical.asReversed() + local + malformed
createVersion8(seeded)
openDatabase()
val dao = database.chatMessageDao()
val restored = dao.getMessagesForSession("session") + dao.getMessagesForSession("local")
assertEquals(seeded.size, restored.size)
seeded.forEach { original ->
val row = restored.single { it.id == original.id }
assertEquals(original, row.copy(sortGroup = 1, sortOrder = 0))
}
assertEquals(canonical.map { it.id }, restored.filter { it.sortGroup == 0 }.map { it.id })
assertTrue(restored.filter { it.id in malformed.map { row -> row.id } }.all { it.sortGroup == 1 })
assertEquals(local.map { it.id }, dao.getMessagesForSession("local").map { it.id })
val first = repository.loadPage("session", null, 2)
var cursor = first.cursor
val pages = mutableListOf(first.messages)
var hasOlder = first.hasOlder
while (hasOlder) {
val page = repository.loadPage("session", cursor, 2)
pages += page.messages
cursor = page.cursor
hasOlder = page.hasOlder
}
assertEquals((canonical + malformed).map { it.id }, pages.asReversed().flatten().map { it.id })
repository.persistMessage(local.first().toUiModel().copy(content = "edited", timestamp = 9999L), "local")
database.close()
openDatabase()
assertEquals(local.map { it.id }, repository.loadPage("local", null, 3).messages.map { it.id })
assertEquals(
"edited",
repository
.loadPage("local", null, 3)
.messages
.first()
.content,
)
assertPagingIndexUsed()
}
@Test
fun migration8to9ValidatesEmptyDatabaseAndAllowsNewWrites() =
runBlocking {
createVersion8(emptyList())
openDatabase()
assertTrue(repository.loadPage("session", null, 1).messages.isEmpty())
repository.persistMessage(restRow(100, 1L), "session")
assertEquals(100L, database.chatMessageDao().getMessage("rest-session-100")?.sortOrder)
assertPagingIndexUsed()
}
@Test
fun confirmedAliasPreservesLivePayloadAndCanonicalOrderAcrossReopen() =
runBlocking {
val live =
ChatMessage(
id = "ffffffff-0000-4000-8000-000000000000",
role = MessageRole.ASSISTANT,
content = "newer WS payload",
reasoningText = "live trace",
completionId = "completion-100",
)
repository.persistMessage(live, "session")
repository.confirmIdentities(
listOf(live.copy(content = "stale snapshot", restId = "rest-session-100")),
"session",
)
// A later WS write with no alias must retain the confirmed cache identity.
repository.persistMessage(live.copy(content = "final payload"), "session")
repository.persistMessage(restRow(9, 9999L), "session")
database.close()
openDatabase()
val rows = repository.loadPage("session", null, 2).messages
assertEquals(listOf("rest-session-9", live.id), rows.map { it.id })
assertEquals("rest-session-100", rows.last().restId)
assertEquals("final payload", rows.last().content)
assertEquals("live trace", rows.last().reasoningText)
assertEquals("completion-100", rows.last().completionId)
}
@Test
fun confirmingLastLocalRowDoesNotReuseItsCursorForALaterInsertion() =
runBlocking {
val live =
ChatMessage(
id = "ffffffff-0000-4000-8000-000000000000",
role = MessageRole.ASSISTANT,
content = "first",
)
repository.persistMessage(live, "session")
val cursor = repository.loadPage("session", null, 1).cursor
repository.confirmIdentities(listOf(live.copy(restId = "rest-session-100")), "session")
val next = live.copy(id = "00000000-0000-4000-8000-000000000000", content = "second")
repository.persistMessage(next, "session")
assertFalse(repository.loadPage("session", cursor, 10).messages.any { it.id == next.id })
assertEquals(
next.id,
repository
.loadPage("session", null, 1)
.messages
.single()
.id,
)
}
private fun driver() = SQLCipherDriver("paging-test-only".toByteArray(), null, null)
/** Build the actual exported v8 schema, then let Room run AND validate its migration on open. */
private fun createVersion8(rows: List<ChatMessageEntity>) {
database.close()
context.deleteDatabase(databaseName)
val schema =
InstrumentationRegistry
.getInstrumentation()
.context.assets
.open("com.m57.hermescontrol.data.local.HermesDatabase/8.json")
.bufferedReader()
.use { JSONObject(it.readText()).getJSONObject("database") }
val file = context.getDatabasePath(databaseName)
file.parentFile?.mkdirs()
driver().open(file.absolutePath).use { connection ->
val entity = schema.getJSONArray("entities").getJSONObject(0)
connection.execSQL(
entity.getString("createSql").replace("\${TABLE_NAME}", "chat_messages"),
)
val indices = entity.getJSONArray("indices")
for (index in 0 until indices.length()) {
connection.execSQL(
indices
.getJSONObject(index)
.getString("createSql")
.replace("\${TABLE_NAME}", "chat_messages"),
)
}
val setup = schema.getJSONArray("setupQueries")
for (index in 0 until setup.length()) connection.execSQL(setup.getString(index))
connection
.prepare(
"INSERT INTO chat_messages (id, session_id, role, content, reasoning_text, timestamp, " +
"tool_name, tool_call_id, tool_status, is_streaming, display_kind, " +
"token_count, tps, completion_id) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
).use { insert ->
rows.forEach { row ->
insert.bindText(1, row.id)
insert.bindText(2, row.sessionId)
insert.bindText(3, row.role)
insert.bindText(4, row.content)
insert.bindText(5, row.reasoningText)
insert.bindLong(6, row.timestamp)
row.toolName?.let { insert.bindText(7, it) } ?: insert.bindNull(7)
insert.bindText(8, row.toolCallId)
row.toolStatus?.let { insert.bindText(9, it) } ?: insert.bindNull(9)
insert.bindLong(10, if (row.isStreaming) 1L else 0L)
row.displayKind?.let { insert.bindText(11, it) } ?: insert.bindNull(11)
row.tokenCount?.let { insert.bindLong(12, it.toLong()) } ?: insert.bindNull(12)
row.tps?.let { insert.bindDouble(13, it) } ?: insert.bindNull(13)
row.completionId?.let { insert.bindText(14, it) } ?: insert.bindNull(14)
insert.step()
insert.reset()
insert.clearBindings()
}
}
connection.execSQL("PRAGMA user_version = 8")
}
}
private fun assertPagingIndexUsed() {
database.close()
driver().open(context.getDatabasePath(databaseName).absolutePath).use { connection ->
for (bound in listOf("", "AND (sort_group, sort_order, id) < (1, 100, 'cursor')")) {
connection
.prepare(
"EXPLAIN QUERY PLAN SELECT * FROM chat_messages WHERE session_id = 'session' $bound " +
"ORDER BY sort_group DESC, sort_order DESC, id DESC LIMIT 151",
).use { query ->
val plan = buildList { while (query.step()) add(query.getText(3)) }.joinToString(" ")
assertTrue(plan, plan.contains("index_chat_messages_session_id_sort_group_sort_order_id"))
assertFalse(plan, plan.contains("TEMP B-TREE", ignoreCase = true))
}
}
}
}
private fun restRow(
id: Int,
timestamp: Long,
) = ChatMessage(
id = "rest-session-$id",
role = MessageRole.ASSISTANT,
content = "answer-$id",
timestamp = timestamp,
)
}
@@ -0,0 +1,252 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.ToolStatus
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/** Real lazy layout regression for the custom23 history prepend jump. */
@RunWith(AndroidJUnit4::class)
class FullBleedChatAnchorTest {
@get:Rule
val compose = createComposeRule()
@Test
fun loadingOnly_atExactTop_keepsContentPixel() = checkAnchor(index = 0, offset = 0, prepend = false)
@Test
fun loadingOnly_atPartialTop_keepsContentPixel() = checkAnchor(index = 0, offset = 23, prepend = false)
@Test
fun prepend_atExactTop_keepsContentPixel() = checkAnchor(index = 0, offset = 0, prepend = true)
@Test
fun prepend_atPartialTop_keepsContentPixel() = checkAnchor(index = 0, offset = 23, prepend = true)
@Test
fun prepend_atInteriorRow_keepsContentPixel() = checkAnchor(index = 8, offset = 17, prepend = true)
@Test
fun prepend_mergingAgentTurn_keepsToolPixel() =
checkAnchor(index = 0, offset = 13, prepend = true, role = MessageRole.TOOL)
@Test
fun loadingOnly_doesNotCancelAnActiveHistoryScroll() =
checkAnchor(index = 8, offset = 17, prepend = false, holdScroll = true)
@Test
fun prepend_usesTheReadersPositionWhenThePageArrives() =
checkAnchor(index = 0, offset = 23, prepend = true, moveWhileLoading = true)
@Test
fun prepend_afterReaderReachesAgentStatus_keepsTailPixel() = checkTailAnchor("agent_status")
@Test
fun prepend_afterReaderReachesClarify_keepsTailPixel() = checkTailAnchor("clarify_bubble")
@Test
fun prepend_afterReaderReachesVaultUnlock_keepsTailPixel() = checkTailAnchor("vault_unlock_card")
@Test
fun prepend_afterReaderReachesVaultSave_keepsTailPixel() = checkTailAnchor("vault_save_login_card")
@Test
fun prepend_afterReaderReachesVaultCode_keepsTailPixel() = checkTailAnchor("vault_code_card")
@Test
fun prepend_duringActiveScroll_keepsGestureAndLiveTail_thenPublishesAtIdle() =
checkAnchor(index = 8, offset = 17, prepend = true, holdScroll = true)
@Test
fun prepend_deferredDuringScroll_doesNotSurviveSessionSwitch() =
checkAnchor(index = 8, offset = 17, prepend = true, holdScroll = true, switchSession = true)
private fun checkTailAnchor(key: String) = checkAnchor(index = 0, offset = 0, prepend = true, tailKey = key)
private fun checkAnchor(
index: Int,
offset: Int,
prepend: Boolean,
role: MessageRole = MessageRole.USER,
holdScroll: Boolean = false,
moveWhileLoading: Boolean = false,
tailKey: String? = null,
switchSession: Boolean = false,
) {
fun message(id: Int) =
ChatMessage(
id = "row-$id",
role = role,
content = "Message $id\nSecond line\nThird line",
toolName = if (role == MessageRole.TOOL) "terminal" else null,
toolStatus = if (role == MessageRole.TOOL) ToolStatus.COMPLETED else null,
)
val messages = mutableStateOf((150..189).map(::message))
val loading = mutableStateOf(false)
val session = mutableStateOf("session-one")
val streaming = mutableStateOf(StreamingState())
val tailKeys =
listOf("agent_status", "clarify_bubble", "vault_unlock_card", "vault_save_login_card", "vault_code_card")
val listState = LazyListState(index, offset)
val viewModel = mockk<ChatViewModel>(relaxed = true)
lateinit var scope: CoroutineScope
compose.setContent {
scope = rememberCoroutineScope()
val controller = rememberChatScrollController(listState, scope)
Box(Modifier.size(width = 320.dp, height = if (tailKey == null) 400.dp else 24.dp)) {
FullBleedChatList(
messages = messages.value,
streamingState = streaming.value,
isAgentTyping = tailKey != null,
searchState = ChatSearchState(),
typingEffectEnabled = false,
typingEffectDelayMs = 30,
isLoading = false,
isLoadingOlder = loading.value,
listState = listState,
scrollController = controller,
lastAnimatedMessageId = null,
onLastAnimatedMessageIdChange = {},
viewModel = viewModel,
pagingSessionId = session.value,
clarifyRequest = if (tailKey != null) ClarifyUi("Choose an option", listOf("Yes")) else null,
vaultUnlockPrompt = if (tailKey != null) VaultUnlockPromptUi(null, null) else null,
vaultSaveLoginPrompt = if (tailKey != null) VaultSaveLoginPromptUi(null, null) else null,
vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null,
)
}
}
var before =
compose.runOnIdle {
listState.layoutInfo.visibleItemsInfo
.first { it.index == index }
.let { it.key to it.offset }
}
fun assertAnchor(stage: String) {
compose.runOnIdle {
val after = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == before.first }
assertNotNull("$stage: anchor ${before.first} left viewport", after)
assertEquals("$stage: anchor ${before.first} moved (pixels)", before.second, after!!.offset)
}
}
val scrollJob =
compose.runOnIdle {
if (holdScroll) {
scope.launch {
listState.scroll(MutatePriority.UserInput) { awaitCancellation() }
}
} else {
null
}
}
compose.runOnIdle {
if (holdScroll) assertTrue("scroll started", listState.isScrollInProgress)
loading.value = true
}
assertAnchor("loading started")
if (tailKey != null) {
compose.runOnIdle { scope.launch { listState.scrollToItem(40 + tailKeys.indexOf(tailKey)) } }
before =
compose.runOnIdle {
listState.layoutInfo.visibleItemsInfo
.first { it.index == listState.firstVisibleItemIndex }
.let { it.key to it.offset }
}
assertEquals("reader reached requested tail row", tailKey, before.first)
}
if (moveWhileLoading) {
compose.runOnIdle { scope.launch { listState.scrollToItem(index + 3, 19) } }
before =
compose.runOnIdle {
listState.layoutInfo.visibleItemsInfo
.first { it.index == listState.firstVisibleItemIndex }
.let { it.key to it.offset }
}
}
compose.runOnIdle {
if (prepend) messages.value = (0..149).map(::message) + messages.value
loading.value = false
}
assertAnchor(if (prepend) "page prepended" else "loading stopped without data")
compose.runOnIdle {
if (holdScroll) {
assertTrue("loading must not cancel the gesture", scrollJob!!.isActive)
assertTrue("scroll still in progress", listState.isScrollInProgress)
}
}
if (holdScroll && prepend) {
compose.runOnIdle {
assertEquals("older prefix waits for idle", 40, listState.layoutInfo.totalItemsCount)
messages.value = messages.value + message(190)
streaming.value =
StreamingState(streamingMessage = ChatMessage("stream", MessageRole.ASSISTANT, "Live answer"))
}
compose.runOnIdle {
assertEquals("new settled and streaming tail remain live", 42, listState.layoutInfo.totalItemsCount)
assertTrue("tail update must not cancel scrolling", scrollJob!!.isActive)
listState.dispatchRawDelta(18f)
}
before =
compose.runOnIdle {
listState.layoutInfo.visibleItemsInfo
.first { it.index == listState.firstVisibleItemIndex }
.let { it.key to it.offset }
}
if (switchSession) {
compose.runOnIdle {
messages.value = (200..239).map(::message)
streaming.value = StreamingState()
session.value = "session-two"
}
compose.runOnIdle {
assertEquals(
"session changes immediately",
40,
listState.layoutInfo.totalItemsCount,
)
}
}
}
compose.runOnIdle { scrollJob?.cancel() }
if (holdScroll && prepend) {
compose.runOnIdle {
assertEquals(
"no lost or stale pending rows",
if (switchSession) 40 else 192,
listState.layoutInfo.totalItemsCount,
)
}
if (!switchSession) assertAnchor("gesture ended after reader moved; prefix published")
}
}
}