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

+1
View File
@@ -10,6 +10,7 @@ plugins {
android { android {
namespace = "com.m57.hermescontrol" namespace = "com.m57.hermescontrol"
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
lint { lint {
disable += "MissingTranslation" disable += "MissingTranslation"
@@ -0,0 +1,136 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "d3d3b8cd95987e6070d2500269bd052f",
"entities": [
{
"tableName": "chat_messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `reasoning_text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_call_id` TEXT NOT NULL, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, `display_kind` TEXT, `token_count` INTEGER, `tps` REAL, `completion_id` TEXT, `rest_id` TEXT, `sort_group` INTEGER NOT NULL DEFAULT 1, `sort_order` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionId",
"columnName": "session_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reasoningText",
"columnName": "reasoning_text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "toolName",
"columnName": "tool_name",
"affinity": "TEXT"
},
{
"fieldPath": "toolCallId",
"columnName": "tool_call_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toolStatus",
"columnName": "tool_status",
"affinity": "TEXT"
},
{
"fieldPath": "isStreaming",
"columnName": "is_streaming",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayKind",
"columnName": "display_kind",
"affinity": "TEXT"
},
{
"fieldPath": "tokenCount",
"columnName": "token_count",
"affinity": "INTEGER"
},
{
"fieldPath": "tps",
"columnName": "tps",
"affinity": "REAL"
},
{
"fieldPath": "completionId",
"columnName": "completion_id",
"affinity": "TEXT"
},
{
"fieldPath": "restId",
"columnName": "rest_id",
"affinity": "TEXT"
},
{
"fieldPath": "sortGroup",
"columnName": "sort_group",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_chat_messages_session_id_sort_group_sort_order_id",
"unique": false,
"columnNames": [
"session_id",
"sort_group",
"sort_order",
"id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_sort_group_sort_order_id` ON `${TABLE_NAME}` (`session_id`, `sort_group`, `sort_order`, `id`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd3d3b8cd95987e6070d2500269bd052f')"
]
}
}
@@ -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")
}
}
}
@@ -1,23 +1,83 @@
package com.m57.hermescontrol.data.local package com.m57.hermescontrol.data.local
import androidx.room3.Dao import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
@Dao @Dao
interface ChatMessageDao { interface ChatMessageDao {
@Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE session_id = :sessionId)") @Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE session_id = :sessionId)")
suspend fun sessionExists(sessionId: String): Boolean suspend fun sessionExists(sessionId: String): Boolean
@Query("SELECT * FROM chat_messages WHERE session_id = :sessionId ORDER BY timestamp ASC") @Query(
"SELECT * FROM chat_messages WHERE session_id = :sessionId " +
"ORDER BY sort_group, sort_order, id",
)
suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity> suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Query(
suspend fun upsert(message: ChatMessageEntity) "SELECT * FROM chat_messages WHERE session_id = :sessionId " +
"ORDER BY sort_group DESC, sort_order DESC, id DESC LIMIT :limit",
)
suspend fun getLatestMessagePage(
sessionId: String,
limit: Int,
): List<ChatMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Query(
suspend fun upsertAll(messages: List<ChatMessageEntity>) "SELECT * FROM chat_messages WHERE session_id = :sessionId " +
"AND (sort_group, sort_order, id) < (:beforeGroup, :beforeOrder, :beforeId) " +
"ORDER BY sort_group DESC, sort_order DESC, id DESC LIMIT :limit",
)
suspend fun getMessagePage(
sessionId: String,
beforeGroup: Int,
beforeOrder: Long,
beforeId: String,
limit: Int,
): List<ChatMessageEntity>
@Query("SELECT * FROM chat_messages WHERE id = :id")
suspend fun getMessage(id: String): ChatMessageEntity?
// Physical insertion sequence includes confirmed aliases, so confirmation cannot reuse a local cursor key.
@Query("SELECT COALESCE(MAX(rowid), 0) + 1 FROM chat_messages")
suspend fun nextLocalOrder(): Long
@Upsert
suspend fun writeMessage(message: ChatMessageEntity)
// Allocate once, in the same transaction as the write. Content/clock updates never move a local row.
@Transaction
suspend fun upsert(message: ChatMessageEntity) {
val existing = getMessage(message.id)
val restId = message.restId ?: existing?.restId
val canonicalOrder = canonicalMessageOrder(restId ?: message.id, message.sessionId)
writeMessage(
message.copy(
restId = restId,
completionId = message.completionId ?: existing?.completionId,
sortGroup = if (canonicalOrder != null) 0 else 1,
sortOrder = canonicalOrder ?: existing?.sortOrder ?: nextLocalOrder(),
),
)
}
@Transaction
suspend fun upsertAll(messages: List<ChatMessageEntity>) {
messages.forEach { upsert(it) }
}
// History confirms identity, but must not overwrite a newer WS-owned payload.
@Transaction
suspend fun confirmIdentity(message: ChatMessageEntity) {
val existing = getMessage(message.id)
upsert(
existing?.copy(restId = message.restId, completionId = existing.completionId ?: message.completionId)
?: message,
)
}
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId") @Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
suspend fun deleteMessagesForSession(sessionId: String) suspend fun deleteMessagesForSession(sessionId: String)
@@ -10,13 +10,13 @@ import androidx.room3.PrimaryKey
* UI model but is stored independently so it survives process death. * UI model but is stored independently so it survives process death.
* *
* Messages are scoped by [sessionId] so switching sessions loads the right * Messages are scoped by [sessionId] so switching sessions loads the right
* thread. The [timestamp] field preserves original ordering even if Room * thread. [sortOrder] preserves numeric server order or local insertion order;
* reorders internally. * [timestamp] is display metadata only.
*/ */
@Entity( @Entity(
tableName = "chat_messages", tableName = "chat_messages",
indices = [ indices = [
Index(value = ["session_id", "timestamp"]), Index(value = ["session_id", "sort_group", "sort_order", "id"]),
], ],
) )
data class ChatMessageEntity( data class ChatMessageEntity(
@@ -48,4 +48,21 @@ data class ChatMessageEntity(
val tps: Double? = null, val tps: Double? = null,
@ColumnInfo(name = "completion_id") @ColumnInfo(name = "completion_id")
val completionId: String? = null, val completionId: String? = null,
@ColumnInfo(name = "rest_id")
val restId: String? = null,
@ColumnInfo(name = "sort_group", defaultValue = "1")
val sortGroup: Int = 1,
@ColumnInfo(name = "sort_order", defaultValue = "0")
val sortOrder: Long = 0,
) )
/** Only the exact session prefix and a nonnegative decimal suffix identify a canonical row. */
internal fun canonicalMessageOrder(
id: String,
sessionId: String,
): Long? {
val prefix = "rest-$sessionId-"
if (!id.startsWith(prefix)) return null
val suffix = id.removePrefix(prefix)
return suffix.takeIf { it.isNotEmpty() && it.all { char -> char in '0'..'9' } }?.toLongOrNull()
}
@@ -38,6 +38,8 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
tokenCount = tokenCount, tokenCount = tokenCount,
tps = tps, tps = tps,
completionId = completionId, completionId = completionId,
restId = restId,
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 },
) )
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity = fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
@@ -56,4 +58,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
tokenCount = tokenCount, tokenCount = tokenCount,
tps = tps, tps = tps,
completionId = completionId, completionId = completionId,
restId = restId,
) )
@@ -14,7 +14,7 @@ import java.io.File
@Database( @Database(
entities = [ChatMessageEntity::class], entities = [ChatMessageEntity::class],
version = 8, version = 9,
exportSchema = true, exportSchema = true,
) )
abstract class HermesDatabase : RoomDatabase() { abstract class HermesDatabase : RoomDatabase() {
@@ -90,6 +90,31 @@ abstract class HermesDatabase : RoomDatabase() {
} }
} }
val MIGRATION_8_9: Migration =
object : Migration(8, 9) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE chat_messages ADD COLUMN rest_id TEXT")
connection.execSQL("ALTER TABLE chat_messages ADD COLUMN sort_group INTEGER NOT NULL DEFAULT 1")
connection.execSQL("ALTER TABLE chat_messages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0")
// v8 has no durable local sequence. Preserve its surviving physical insertion order.
connection.execSQL("UPDATE chat_messages SET sort_order = rowid")
val suffix = "substr(id, length(session_id) + 7)"
val digits = "ltrim($suffix, '0')"
connection.execSQL(
"UPDATE chat_messages SET sort_group = 0, sort_order = CAST($suffix AS INTEGER) " +
"WHERE substr(id, 1, length(session_id) + 6) = 'rest-' || session_id || '-' " +
"AND $suffix != '' AND $suffix NOT GLOB '*[^0-9]*' " +
"AND (length($digits) < 19 OR " +
"(length($digits) = 19 AND $digits <= '9223372036854775807'))",
)
connection.execSQL("DROP INDEX index_chat_messages_session_id_timestamp")
connection.execSQL(
"CREATE INDEX index_chat_messages_session_id_sort_group_sort_order_id " +
"ON chat_messages (session_id, sort_group, sort_order, id)",
)
}
}
suspend fun get(context: Context): HermesDatabase = suspend fun get(context: Context): HermesDatabase =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
instance?.let { return@withContext it } instance?.let { return@withContext it }
@@ -126,6 +151,7 @@ abstract class HermesDatabase : RoomDatabase() {
MIGRATION_5_6, MIGRATION_5_6,
MIGRATION_6_7, MIGRATION_6_7,
MIGRATION_7_8, MIGRATION_7_8,
MIGRATION_8_9,
).fallbackToDestructiveMigration(false) ).fallbackToDestructiveMigration(false)
.build() .build()
.also { instance = it } .also { instance = it }
@@ -87,8 +87,16 @@ data class ChatMessage(
val tps: Double? = null, val tps: Double? = null,
/** Stable completion identity correlating with reply notifications for read tracking. */ /** Stable completion identity correlating with reply notifications for read tracking. */
val completionId: String? = null, val completionId: String? = null,
/** Confirmed transcript identity (#859); [id] remains the stable live/render key. */
val restId: String? = null,
/** Cache insertion sequence for unconfirmed local rows; null before first persistence. */
val localOrder: Long? = null,
) )
/** Cached REST rows already carry their canonical identity in the persisted primary key. */
internal val ChatMessage.canonicalRestId: String?
get() = restId ?: id.takeIf { it.startsWith("rest-") }
/** /**
* Single live transcript log entry for subagent execution. * Single live transcript log entry for subagent execution.
*/ */
@@ -36,6 +36,50 @@ open class ChatPersistenceRepository(
suspend fun loadMessages(sessionId: String): List<ChatMessage> = suspend fun loadMessages(sessionId: String): List<ChatMessage> =
daoProvider().getMessagesForSession(sessionId).map { it.toUiModel() } daoProvider().getMessagesForSession(sessionId).map { it.toUiModel() }
data class Cursor(
val group: Int,
val order: Long,
val id: String,
)
data class Page(
val messages: List<ChatMessage>,
val cursor: Cursor?,
val hasOlder: Boolean,
)
/** Read at most one page plus a lookahead row; never trim a full-session query. */
suspend fun loadPage(
sessionId: String,
before: Cursor?,
limit: Int,
): Page {
require(limit in 1..1_000)
val dao = daoProvider()
val rows =
if (before == null) {
dao.getLatestMessagePage(sessionId, limit + 1)
} else {
dao.getMessagePage(sessionId, before.group, before.order, before.id, limit + 1)
}
val page = rows.take(limit)
val oldest = page.lastOrNull()
return Page(
messages = page.asReversed().map { it.toUiModel() },
cursor = oldest?.let { Cursor(it.sortGroup, it.sortOrder, it.id) } ?: before,
hasOlder = rows.size > limit,
)
}
/** Record confirmed UUID aliases without replacing their newer locally persisted content. */
suspend fun confirmIdentities(
messages: List<ChatMessage>,
sessionId: String,
) {
val dao = daoProvider()
messages.forEach { dao.confirmIdentity(it.toEntity(sessionId)) }
}
/** Clear all cached messages for a session (e.g. after /undo rewind). */ /** Clear all cached messages for a session (e.g. after /undo rewind). */
suspend fun clearMessagesForSession(sessionId: String) { suspend fun clearMessagesForSession(sessionId: String) {
daoProvider().deleteMessagesForSession(sessionId) daoProvider().deleteMessagesForSession(sessionId)
@@ -59,7 +59,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -115,8 +114,6 @@ import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
import com.m57.hermescontrol.util.ConnectorUrlValidator import com.m57.hermescontrol.util.ConnectorUrlValidator
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val SESSION_SYNC_INTERVAL_MS = 30_000L private const val SESSION_SYNC_INTERVAL_MS = 30_000L
@@ -211,7 +208,6 @@ fun ChatScreen(
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy) val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
val scrollScope = rememberCoroutineScope() val scrollScope = rememberCoroutineScope()
val scrollController = rememberChatScrollController(listState, scrollScope) val scrollController = rememberChatScrollController(listState, scrollScope)
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
var showContextSheet by remember { mutableStateOf(false) } var showContextSheet by remember { mutableStateOf(false) }
var pendingSavePath by rememberSaveable { mutableStateOf<String?>(null) } var pendingSavePath by rememberSaveable { mutableStateOf<String?>(null) }
var pendingSaveName by rememberSaveable { mutableStateOf<String?>(null) } var pendingSaveName by rememberSaveable { mutableStateOf<String?>(null) }
@@ -265,38 +261,9 @@ fun ChatScreen(
} }
} }
// Arm + trigger older-message paging when the user scrolls near the top. // FullBleedChatList preserves the live lazy row key and pixel offset on prepend.
// Before prepending, capture the anchor (first visible message id + offset) // Never restore a message index captured before an asynchronous fetch: it is not
// so the same content stays under the reader's eye after the insert // a lazy row index and the reader may have moved in the meantime.
// (issue #682). Restored in a LaunchedEffect once the page actually lands.
val pagingAnchor = remember { mutableStateOf<Pair<String, Int>?>(null) }
LaunchedEffect(listState, state.currentSessionId, state.hasOlderMessages, state.isLoadingOlder) {
if (!state.hasOlderMessages || state.isLoadingOlder) return@LaunchedEffect
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collectLatest { firstVisibleIndex ->
if (firstVisibleIndex > 2) {
isOlderPagingArmed = true
} else if (isOlderPagingArmed || state.messages.size <= 3) {
val anchorId = state.messages.getOrNull(firstVisibleIndex)?.id
val offset = scrollController.captureAnchorOffset()
pagingAnchor.value = if (anchorId != null) anchorId to offset else null
viewModel.loadOlderMessages()
}
}
}
// After older history is prepended, restore the anchor message (by id, so
// streaming-token inserts during paging don't skew the index) plus offset.
LaunchedEffect(state.messages) {
val anchor = pagingAnchor.value ?: return@LaunchedEffect
val (anchorId, offset) = anchor
val index = state.messages.indexOfFirst { it.id == anchorId }
if (index >= 0) {
scrollController.scrollToItem(index, offset)
pagingAnchor.value = null
}
}
// Continuous bottom-follow tracking from LazyListState (issue #682). // Continuous bottom-follow tracking from LazyListState (issue #682).
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -705,6 +672,8 @@ fun ChatScreen(
maxToolCallsPerTurn = state.maxToolCallsPerTurn, maxToolCallsPerTurn = state.maxToolCallsPerTurn,
isLoading = state.isLoading, isLoading = state.isLoading,
isLoadingOlder = state.isLoadingOlder, isLoadingOlder = state.isLoadingOlder,
hasOlderMessages = state.hasOlderMessages,
pagingSessionId = state.currentSessionId,
listState = listState, listState = listState,
scrollController = scrollController, scrollController = scrollController,
lastAnimatedMessageId = lastAnimatedMessageId, lastAnimatedMessageId = lastAnimatedMessageId,
@@ -10,10 +10,9 @@ import com.m57.hermescontrol.notification.ReplyNotificationTracker
/** /**
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s. * Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
* *
* Verbatim extraction of ChatViewModel.mapServerMessages (corral 2 of the * Reads the supplied transcript snapshot and recovers notification identity through
* 2026-08 delegate refactor). Pure with respect to the VM: it reads the live * ReplyNotificationTracker's synchronized lookup. Safe on the history dispatcher;
* message list supplied by the caller and one paging-mode flag — no state * it never mutates ViewModel state or dismisses a notification.
* mutation, no I/O beyond URL construction.
*/ */
internal fun mapServerMessages( internal fun mapServerMessages(
sessionId: String, sessionId: String,
@@ -24,15 +23,11 @@ internal fun mapServerMessages(
isPagingOlder: Boolean = false, isPagingOlder: Boolean = false,
context: android.content.Context? = null, context: android.content.Context? = null,
): List<ChatMessage> { ): List<ChatMessage> {
val existingReasoningMap = val existingById = liveMessages.associateBy { it.canonicalRestId ?: it.id }
liveMessages
.filter { it.reasoningText.isNotBlank() }
.associateBy { it.content }
val liveByExactId = val liveByExactId =
liveMessages liveMessages
.filter { !it.completionId.isNullOrBlank() } .filter { !it.completionId.isNullOrBlank() }
.associateBy { it.id } .associateBy { it.canonicalRestId ?: it.id }
val unmappedLiveWsAssistants = val unmappedLiveWsAssistants =
if (isPagingOlder) { if (isPagingOlder) {
@@ -40,112 +35,91 @@ internal fun mapServerMessages(
} else { } else {
liveMessages liveMessages
.filter { .filter {
it.role == MessageRole.ASSISTANT && !it.id.startsWith("rest-") && it.role == MessageRole.ASSISTANT && it.canonicalRestId == null &&
!it.completionId.isNullOrBlank() !it.completionId.isNullOrBlank()
} }
} }
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>() fun restIdAt(index: Int): String =
if (!isPagingOlder) { if (latestPaging) {
if (unmappedLiveWsAssistants.isNotEmpty()) { "rest-$sessionId-${requireNotNull(messages[index].id) { "Latest transcript row has no stable id" }}"
val remainingWs = unmappedLiveWsAssistants.toMutableList() } else {
for (i in messages.indices.reversed()) { "rest-$sessionId-${offset + index}"
if (remainingWs.isEmpty()) break
val m = messages[i]
val r =
when (m.role?.lowercase()) {
"user" -> MessageRole.USER
"system" -> MessageRole.SYSTEM
"tool" -> MessageRole.TOOL
else -> MessageRole.ASSISTANT
}
if (r != MessageRole.ASSISTANT) continue
val rawContent = m.contentText
// Reasoning-only or empty tool placeholder: skip, does not become visible prose
if (rawContent.isBlank() && !rawContent.contains("MEDIA:")) continue
val restId =
if (latestPaging) {
m.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-${offset + i}"
} else {
"rest-$sessionId-${offset + i}"
}
if (liveByExactId.containsKey(restId)) continue
val canonicalContent =
if (rawContent.contains("MEDIA:")) {
HostMediaExtractor.strip(rawContent).trim()
} else {
rawContent.trim()
}
val wsIdx = remainingWs.indexOfLast { it.content.trim() == canonicalContent }
if (wsIdx >= 0) {
remainingWs.removeAt(wsIdx).completionId?.let { compId ->
wsCompletionIdByRestIndex[i] = compId
}
}
}
} }
// REST-only recovery is fail-closed: only a durable server row ID may val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
// transfer notification identity after the WebSocket copy is gone. if (!isPagingOlder) {
val activeTarget = ReplyNotificationTracker.getActiveTarget(context) // #129: reserve durable identity before matching any repeated live prose.
if ( // REST-only recovery remains fail-closed when the target row is absent.
activeTarget != null && val activeTarget = ReplyNotificationTracker.getActiveTarget(context)?.takeIf { it.sessionId == sessionId }
activeTarget.sessionId == sessionId && val durableTarget = activeTarget?.takeIf { it.serverMessageId != null }
activeTarget.serverMessageId != null && if (durableTarget != null) {
wsCompletionIdByRestIndex.values.none { it == activeTarget.completionId }
) {
val exactIndex = val exactIndex =
messages.indexOfFirst { message -> messages.indexOfFirst { message ->
message.id == activeTarget.serverMessageId && message.id == durableTarget.serverMessageId &&
message.role.equals("assistant", ignoreCase = true) message.role.equals("assistant", ignoreCase = true)
} }
if (exactIndex >= 0 && !liveByExactId.containsKey("rest-$sessionId-${activeTarget.serverMessageId}")) { if (exactIndex >= 0 && !liveByExactId.containsKey(restIdAt(exactIndex))) {
wsCompletionIdByRestIndex[exactIndex] = activeTarget.completionId wsCompletionIdByRestIndex[exactIndex] = durableTarget.completionId
}
}
val confirmedCompletions =
liveMessages.filter { it.canonicalRestId != null }.mapNotNull { it.completionId }.toSet()
val remainingWs =
unmappedLiveWsAssistants
.filter {
it.completionId !in confirmedCompletions && it.completionId != durableTarget?.completionId
}.toMutableList()
// #129: reserve known completions and live aliases. A metadata-free cached REST
// echo must remain eligible for its pending WS completion before older duplicates.
val reservedRestIds = liveByExactId.keys + liveMessages.mapNotNull { it.restId }
// Retain upstream's newest-to-newest, one-to-one WS echo matching.
for (i in messages.indices.reversed()) {
if (remainingWs.isEmpty()) break
if (i in wsCompletionIdByRestIndex || restIdAt(i) in reservedRestIds) continue
val m = messages[i]
if (m.role?.lowercase() in listOf("user", "system", "tool")) continue
val rawContent = m.contentText
if (rawContent.isBlank()) continue
val canonicalContent =
if (rawContent.contains("MEDIA:")) HostMediaExtractor.strip(rawContent).trim() else rawContent.trim()
val wsIdx = remainingWs.indexOfLast { it.content.trim() == canonicalContent }
if (wsIdx >= 0) {
remainingWs.removeAt(wsIdx).completionId?.let { wsCompletionIdByRestIndex[i] = it }
} }
} }
} }
// Tool rows in the REST transcript carry NO tool name — the live WS // Reasoning follows the same reserved identities as completions, never a reusable text lookup.
// stream was the only source of `toolName`. Match each REST tool row val assistants = liveMessages.filter { it.role == MessageRole.ASSISTANT }
// to its WS counterpart by RESULT CONTENT (not position — pagination val assistantsByRestId = assistants.groupBy { it.canonicalRestId }
// and mixed cache state make positional mapping misalign, leaving val assistantsByCompletion = assistants.groupBy { it.completionId }
// the newest call with a null name → generic "tool" bubble). When a val reasoningSources = arrayOfNulls<ChatMessage>(messages.size)
// match is found the live message is reused wholesale (same id + messages.forEachIndexed { index, row ->
// toolName + rich payload), so persistence upserts the same row if (row.role?.lowercase() in listOf("user", "system", "tool")) return@forEachIndexed
// instead of accumulating a second `rest-` copy in Room. Issue #771. val exact = assistantsByRestId[restIdAt(index)].orEmpty()
val liveToolByResult = linkedMapOf<String, ChatMessage>() val completion = liveByExactId[restIdAt(index)]?.completionId ?: wsCompletionIdByRestIndex[index]
liveMessages val candidates = exact + completion?.let { assistantsByCompletion[it] }.orEmpty()
.filter { it.role == MessageRole.TOOL } reasoningSources[index] = candidates.firstOrNull { it.reasoningText.isNotBlank() } ?: candidates.firstOrNull()
.sortedBy { it.id.startsWith("rest-") } // prefer live WS copies }
.forEach { msg -> if (!isPagingOlder) {
canonicalToolResultKey(msg.content)?.let { key -> val remaining =
liveToolByResult.putIfAbsent(key, msg) assistants.filter { it.canonicalRestId == null && it.completionId == null }.toMutableList()
for (index in messages.indices.reversed()) {
val row = messages[index]
if (reasoningSources[index] != null || row.contentText.isBlank() ||
row.role?.lowercase() in listOf("user", "system", "tool") ||
liveByExactId[restIdAt(index)]?.completionId != null || index in wsCompletionIdByRestIndex
) {
continue
} }
val content = HostMediaExtractor.strip(row.contentText).trim()
val match = remaining.indexOfLast { it.content.trim() == content }
if (match >= 0) reasoningSources[index] = remaining.removeAt(match)
} }
}
// Issue #842: REST transcript rows carry the gateway's `tool_call_id`
// — prefer matching live bubbles by that 1:1 identity. It works for
// EVERY tool shape, including MCP/web rows whose REST copy is raw
// `<untrusted_tool_result>` text with no JSON key to canonicalize.
val liveToolByCallId = linkedMapOf<String, ChatMessage>()
liveMessages
.filter { it.role == MessageRole.TOOL && it.toolCallId.isNotBlank() }
.sortedBy { it.id.startsWith("rest-") } // prefer live WS copies
.forEach { msg ->
liveToolByCallId.putIfAbsent(msg.toolCallId, msg)
}
val mapped = mutableListOf<ChatMessage>() val mapped = mutableListOf<ChatMessage>()
// The gateway stores a reasoning-model's thinking as its OWN assistant
// row (content = "", reasoning = trace) directly before the answer row.
// Rendering that as a standalone empty assistant bubble is the
// "reasoning box in a separate bubble" artifact — fold it into the
// next assistant message with content instead. Issue #771.
var pendingReasoning: String? = null
messages.forEachIndexed { index, msg -> messages.forEachIndexed { index, msg ->
val role = val role =
when (msg.role?.lowercase()) { when (msg.role?.lowercase()) {
@@ -154,47 +128,32 @@ internal fun mapServerMessages(
"tool" -> MessageRole.TOOL "tool" -> MessageRole.TOOL
else -> MessageRole.ASSISTANT else -> MessageRole.ASSISTANT
} }
val globalIndex = offset + index
// Issue #859: under newest-anchored paging use the server's // Issue #859: under newest-anchored paging use the server's
// AUTOINCREMENT row id as the stable key — from-end positions shift // AUTOINCREMENT row id as the stable key — from-end positions shift
// as the transcript grows and would collide across hydrations // as the transcript grows and would collide across hydrations
// (distinctBy would silently drop the newest copy). Legacy paging // (distinctBy would silently drop the newest copy). Legacy paging
// keeps the absolute-position key its count-based sync math needs. // keeps the absolute-position key its count-based sync math needs.
val restId = val restId = restIdAt(index)
if (latestPaging) {
msg.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-$globalIndex"
} else {
"rest-$sessionId-$globalIndex"
}
val timestamp = val timestamp =
msg.timestampText msg.timestampText
?.toDoubleOrNull() ?.toDoubleOrNull()
?.times(1000) ?.times(1000)
?.toLong() ?.toLong()
?: existingById[restId]?.timestamp
?: System.currentTimeMillis() ?: System.currentTimeMillis()
val rawContent = msg.contentText val rawContent = msg.contentText
val rowReasoning = val rowReasoning =
if (msg.reasoningText.isNotBlank()) { msg.reasoningText.ifBlank {
msg.reasoningText if (role == MessageRole.ASSISTANT) {
} else { reasoningSources[index]?.reasoningText.orEmpty()
existingReasoningMap[rawContent]?.reasoningText.orEmpty() } else {
existingById[restId]?.reasoningText.orEmpty()
}
} }
// Empty assistant row — two cases stored by the gateway: // Retain canonical reasoning rows across page boundaries; hide only textless placeholders.
// 1. Reasoning-only: thinking-model split storage (content = "", if (role == MessageRole.ASSISTANT && rawContent.isBlank() && rowReasoning.isBlank()) return@forEachIndexed
// reasoning = trace). Stash the trace and fold it into the
// next assistant message that has content (issue #771).
// 2. Tool-call placeholder: non-reasoning models emit content = ""
// with tool_calls metadata and no reasoning. These carry no
// user-visible text and must not render as empty bubbles
// (issue #956).
if (role == MessageRole.ASSISTANT && rawContent.isBlank()) {
if (rowReasoning.isNotBlank()) {
pendingReasoning = rowReasoning
}
return@forEachIndexed
}
// Context-reference preprocessing can persist model-facing attachment // Context-reference preprocessing can persist model-facing attachment
// contents after the user's text. A cold reload / second device has no // contents after the user's text. A cold reload / second device has no
@@ -232,34 +191,6 @@ internal fun mapServerMessages(
} }
} }
val finalReasoning =
if (rowReasoning.isNotBlank()) {
rowReasoning
} else if (role == MessageRole.ASSISTANT && pendingReasoning != null) {
pendingReasoning.also { pendingReasoning = null }
} else {
""
}
// Tool rows in the REST transcript carry no tool name. When the
// result payload matches a live WS tool message, reuse it whole —
// keeps the real name, the rich WS payload, AND the same id so
// Room upserts instead of accumulating a duplicate `rest-` row.
if (role == MessageRole.TOOL) {
// Prefer the gateway call id (1:1, works for every tool
// shape), then fall back to result-content matching.
liveToolByCallId[msg.toolCallId]?.let { live ->
mapped.add(live)
return@forEachIndexed
}
canonicalToolResultKey(rawContent)?.let { key ->
liveToolByResult[key]?.let { live ->
mapped.add(live)
return@forEachIndexed
}
}
}
val completionId = val completionId =
if (role == MessageRole.ASSISTANT) { if (role == MessageRole.ASSISTANT) {
liveByExactId[restId]?.completionId ?: wsCompletionIdByRestIndex[index] liveByExactId[restId]?.completionId ?: wsCompletionIdByRestIndex[index]
@@ -272,7 +203,7 @@ internal fun mapServerMessages(
id = restId, id = restId,
role = role, role = role,
content = finalContent, content = finalContent,
reasoningText = finalReasoning, reasoningText = rowReasoning,
toolCallId = msg.toolCallId, toolCallId = msg.toolCallId,
attachments = attachments, attachments = attachments,
timestamp = timestamp, timestamp = timestamp,
@@ -284,17 +215,12 @@ internal fun mapServerMessages(
) )
} }
// A reasoning-only row with no following answer (interrupted turn): // REST echoes must not reserve a match before the richer WS copy of that tool.
// don't drop the trace — attach it to the last assistant message. val liveTools = liveMessages.filter { it.role == MessageRole.TOOL && !it.id.startsWith("rest-") }
if (pendingReasoning != null) { val mappedTools = mapped.filter { it.role == MessageRole.TOOL }
val lastAssistantIdx = mapped.indexOfLast { it.role == MessageRole.ASSISTANT } val matches = matchTranscriptMessages(mappedTools, liveTools)
if (lastAssistantIdx >= 0) { val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] }
val target = mapped[lastAssistantIdx] return mapped.map { message ->
if (target.reasoningText.isBlank()) { toolsById[message.id]?.copy(restId = message.canonicalRestId) ?: message
mapped[lastAssistantIdx] = target.copy(reasoningText = pendingReasoning)
}
}
} }
return mapped
} }
@@ -107,48 +107,120 @@ internal fun canonicalToolResultKey(content: String): String? {
internal fun sameLogicalMessage( internal fun sameLogicalMessage(
a: ChatMessage, a: ChatMessage,
b: ChatMessage, b: ChatMessage,
): Boolean { ): Boolean = TranscriptComparison().same(a, b)
if (a.role != b.role) return false
if (a.role == MessageRole.TOOL) { /** One operation owns this cache; even invalid JSON is parsed at most once per content. */
// Issue #842: prefer the gateway's tool call id when both sides carry internal class TranscriptComparison(
// it — the REST transcript stores `tool_call_id` and the live WS private val canonicalize: (String) -> String? = ::canonicalToolResultKey,
// bubble keeps it from `tool.start`. Content canonicalization cannot ) {
// cover MCP/web tools: the REST side stores the payload as raw private val toolKeys = HashMap<String, String?>()
// `<untrusted_tool_result>` text (not JSON), so it has no key at all. private val trimmed = HashMap<String, String>()
if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) { private val captions = HashMap<String, String>()
return a.toolCallId == b.toolCallId
fun toolKey(content: String): String? {
if (!toolKeys.containsKey(content)) toolKeys[content] = canonicalize(content)
return toolKeys[content]
}
fun same(
a: ChatMessage,
b: ChatMessage,
): Boolean {
if (a.role != b.role) return false
val aRestId = a.canonicalRestId
val bRestId = b.canonicalRestId
if (aRestId != null && bRestId != null) return aRestId == bRestId
// #129: completion-bearing replies need confirmed identity, not repeated prose.
// Older pages deliberately do not acquire a live reply's completion in the mapper.
if (a.role == MessageRole.ASSISTANT && (a.completionId != null || b.completionId != null)) {
return a.completionId != null && a.completionId == b.completionId
} }
val ka = canonicalToolResultKey(a.content) if (a.role == MessageRole.ASSISTANT && a.content.isBlank() && b.content.isBlank() &&
val kb = canonicalToolResultKey(b.content) (a.reasoningText.isNotBlank() || b.reasoningText.isNotBlank())
return ka != null && ka == kb ) {
return a.reasoningText.isNotBlank() && a.reasoningText == b.reasoningText
}
if (a.role == MessageRole.TOOL) {
if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) return a.toolCallId == b.toolCallId
val key = toolKey(a.content)
return key != null && key == toolKey(b.content)
}
val ta = trimmed.getOrPut(a.content) { a.content.trim() }
val tb = trimmed.getOrPut(b.content) { b.content.trim() }
if (ta == tb) return true
if (a.role == MessageRole.USER &&
captions.getOrPut(ta) { stripAttachmentRefLines(ta) } ==
captions.getOrPut(tb) { stripAttachmentRefLines(tb) }
) {
return true
}
return ta.length >= 40 && tb.length >= 40 && (tb.startsWith(ta) || ta.startsWith(tb))
} }
val ta = a.content.trim() }
val tb = b.content.trim()
if (ta == tb) return true /** Consume each occurrence once, reserving exact IDs before considering content echoes. */
// Attachment dedupe: when the user sends an image/file, the backend internal fun matchTranscriptMessages(
// persists an ENRICHED copy of the prompt — the real caption plus incoming: List<ChatMessage>,
// `@image:`/`@file:` reference lines and a `[screenshot]` marker existing: List<ChatMessage>,
// (tui_gateway _build_image_ref_message / run_agent flattening). The comparison: TranscriptComparison = TranscriptComparison(),
// optimistic bubble carries the raw caption only, so the exact compare allowAssistantContentMatches: Boolean = true,
// above fails and the REST sync renders the same message twice. Compare ): List<ChatMessage?> {
// USER rows on the caption with injection lines stripped (both sides — val byId = existing.withIndex().associate { it.value.id to it.index }
// stripping a clean string is a no-op). USER-only so assistant text that val byRestId =
// legitimately mentions these tokens is never collapsed. existing.withIndex().mapNotNull { (index, message) -> message.canonicalRestId?.let { it to index } }.toMap()
if (a.role == MessageRole.USER) { val used = BooleanArray(existing.size)
val ca = stripAttachmentRefLines(ta) val matches = arrayOfNulls<ChatMessage>(incoming.size)
val cb = stripAttachmentRefLines(tb) incoming.forEachIndexed { index, message ->
if (ca == cb) return true (byId[message.id] ?: message.canonicalRestId?.let { byRestId[it] })?.let { match ->
if (!used[match]) {
used[match] = true
matches[index] = existing[match]
}
}
} }
// Issue #842: a seal race can leave the live orphan a few trailing tokens val byRole = existing.indices.groupBy { existing[it].role }
// short of the streamed narration (the last delta was still in the val byCall =
// throttled buffer when tool.start sealed the message). The backend existing.indices
// persists the COMPLETE copy, so the orphan is a strict prefix of the .filter { existing[it].toolCallId.isNotBlank() }
// REST row. Accept prefix-covering only for substantial texts (>=40 .groupBy { existing[it].toolCallId }
// chars) so a short reply can never be swallowed by a longer message val byResult =
// that merely starts with it. existing.indices
return ta.length >= 40 && .filter { existing[it].role == MessageRole.TOOL }
tb.length >= 40 && .groupBy { comparison.toolKey(existing[it].content) }
(tb.startsWith(ta) || ta.startsWith(tb)) val matchOrder =
incoming.indices.filter { incoming[it].role != MessageRole.ASSISTANT } +
incoming.indices.reversed().filter { incoming[it].role == MessageRole.ASSISTANT }
matchOrder.forEach { index ->
val message = incoming[index]
if (matches[index] != null) return@forEach
val candidates =
if (message.role == MessageRole.TOOL) {
val callMatches = byCall[message.toolCallId].orEmpty()
callMatches + byResult[comparison.toolKey(message.content)].orEmpty()
} else {
// Prefix matching scans only candidates with the same role.
byRole[message.role].orEmpty().let {
if (message.role == MessageRole.ASSISTANT) it.asReversed() else it
}
}
val match =
candidates.firstOrNull { candidate ->
val other = existing[candidate]
!used[candidate] &&
(
allowAssistantContentMatches || message.role != MessageRole.ASSISTANT ||
message.completionId != null || other.completionId != null
) &&
// Distinct IDs within the same source are separate occurrences, not echoes.
((message.canonicalRestId == null) != (other.canonicalRestId == null)) &&
comparison.same(message, other)
}
if (match != null) {
used[match] = true
matches[index] = existing[match]
}
}
return matches.toList()
} }
/** /**
@@ -176,55 +248,154 @@ internal fun stripAttachmentRefLines(content: String): String =
* same call twice. Drop the `rest-` copy whenever a WS copy of the same * same call twice. Drop the `rest-` copy whenever a WS copy of the same
* logical message exists (issue #771). * logical message exists (issue #771).
*/ */
internal fun dedupeCachedMessages(messages: List<ChatMessage>): List<ChatMessage> { internal fun dedupeCachedMessages(
val rest = messages.filter { it.id.startsWith("rest-") } messages: List<ChatMessage>,
if (rest.isEmpty()) return messages confirmedOnly: Boolean = false,
val nonRest = messages.filterNot { it.id.startsWith("rest-") } ): List<ChatMessage> {
if (nonRest.isEmpty()) return messages val unique = messages.dedupeById()
val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } } val rest = unique.filter { it.id.startsWith("rest-") }
return (nonRest + keepRest).sortedBy { it.timestamp } val live = unique.filterNot { it.id.startsWith("rest-") }
if (rest.isEmpty() || live.isEmpty()) return unique
val matches =
matchTranscriptMessages(rest, live).mapIndexed { index, match ->
match?.takeIf {
!confirmedOnly || rest[index].canonicalRestId == it.canonicalRestId ||
(rest[index].completionId != null && rest[index].completionId == it.completionId)
}
}
val echoes =
rest.indices
.filter { matches[it] != null }
.map { rest[it].id }
.toSet()
// #859: retain the REST identity when keeping a rich UUID row, including across cache pages.
val aliases =
rest.indices
.mapNotNull { index ->
matches[index]?.id?.let { it to rest[index] }
}.toMap()
return unique.filterNot { it.id in echoes }.map { message ->
aliases[message.id]?.let {
message.copy(restId = it.canonicalRestId, completionId = message.completionId ?: it.completionId)
} ?: message
}
} }
/** /** Cache pages enrich confirmed identities without changing keys already on screen (#859). */
* A transcript reload must NOT yank live WS bubbles the server has not internal fun mergeCachedTranscriptPage(
* persisted yet. The gateway stores a tool row only once the tool page: List<ChatMessage>,
* COMPLETES server-side, so a reload that lands while a tool is running current: List<ChatMessage>,
* (app background/foreground mid-turn, reconnect re-resume, pull-refresh) ): List<ChatMessage> {
* returns a page without the tool row — replacing the list outright made val currentById = current.associateBy { it.id }
* the in-flight tool bubble vanish and left tool.complete with no RUNNING // Legacy cached UUIDs may lack an alias. Restore a known alias before page-local matching
* message to update (issue #771). // so an older identical REST row cannot claim that UUID again.
* val incoming =
* Merge instead of replace: append any current message the REST page does dedupeCachedMessages(
* not already cover (checked by id AND logical content via page.map { message ->
* [sameLogicalMessage]) and keep chronological order. currentById[message.id]?.restId?.let { message.copy(restId = it) } ?: message
*/ },
)
val matches = matchTranscriptMessages(incoming, current)
val replacements =
incoming
.mapIndexedNotNull { index, message ->
val match = matches[index] ?: return@mapIndexedNotNull null
val rich = if (match.id.startsWith("rest-") && !message.id.startsWith("rest-")) message else match
match.id to
rich.copy(
id = match.id,
restId = message.canonicalRestId ?: match.canonicalRestId,
completionId = match.completionId ?: message.completionId,
)
}.toMap()
return (
current.map { replacements[it.id] ?: it } +
incoming.filterIndexed { index, _ -> matches[index] == null }
).dedupeById().inTranscriptOrder().reconcileReasoningRows()
}
/** Merge one page with the current snapshot without consuming repeated results more than once. */
internal fun mergeTranscriptWithLive( internal fun mergeTranscriptWithLive(
restMessages: List<ChatMessage>, restMessages: List<ChatMessage>,
currentMessages: List<ChatMessage>, currentMessages: List<ChatMessage>,
chronological: Boolean = true,
preserveLiveIds: Boolean = false,
): List<ChatMessage> { ): List<ChatMessage> {
val dedupedRest = restMessages.dedupeById() val incoming = restMessages.dedupeById()
val dedupedCurrent = currentMessages.dedupeById() val current = currentMessages.dedupeById()
// User rows can carry live/cache-only metadata (for example whether the val matches = matchTranscriptMessages(incoming, current, allowAssistantContentMatches = chronological)
// bubble continues the active turn). Keep that richer copy when REST val consumed = matches.mapNotNull { it?.id }.toSet()
// returns the same logical row. val merged =
val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList() incoming.mapIndexed { index, message ->
val mergedRest = val match = matches[index]
dedupedRest.map { rest -> // Keep local user metadata and stable IDs already used by the renderer.
if (rest.role != MessageRole.USER) return@map rest when {
val matchIndex = match?.role == MessageRole.USER -> {
currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 } match.copy(
?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) } restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest )
}
preserveLiveIds && match != null && !match.id.startsWith("rest-") -> {
match.copy(
restId = message.canonicalRestId ?: match.canonicalRestId,
content = message.content,
timestamp = message.timestamp,
isStreaming = message.isStreaming,
reasoningText = message.reasoningText.ifBlank { match.reasoningText },
attachments = message.attachments ?: match.attachments,
toolName = message.toolName ?: match.toolName,
toolCallId = message.toolCallId.ifBlank { match.toolCallId },
toolStatus = message.toolStatus ?: match.toolStatus,
displayKind = message.displayKind ?: match.displayKind,
tokenCount = message.tokenCount ?: match.tokenCount,
completionId = message.completionId ?: match.completionId,
)
}
else -> {
message.copy(completionId = message.completionId ?: match?.completionId)
}
}
} }
val restIds = dedupedRest.map { it.id }.toSet() val transcript =
val liveTail = if (chronological) {
dedupedCurrent.filter { old -> current.filterNot { it.id in consumed } + merged
old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) } } else {
merged + current.filterNot { it.id in consumed }
} }
if (liveTail.isEmpty()) return mergedRest.dedupeById() // #129: a mapped completion can confirm a cached REST row while its rich UUID
return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp } // copy is also present. Fold that now-confirmed echo without changing the live key.
// The page has already consumed its content matches; consuming another would
// collapse a separate repeated occurrence. Only confirmed identities can fold here.
return dedupeCachedMessages(transcript.inTranscriptOrder(), confirmedOnly = true).reconcileReasoningRows()
} }
/** Numeric canonical history first, then unconfirmed local insertion order; clocks never sort a transcript. */
private fun List<ChatMessage>.inTranscriptOrder(): List<ChatMessage> =
sortedWith(
compareBy<ChatMessage> { it.canonicalOrder == null }
.thenBy { it.canonicalOrder ?: it.localOrder ?: Long.MAX_VALUE },
)
private val ChatMessage.canonicalOrder: Long?
get() = if (localOrder != null && restId == null) null else canonicalRestId?.substringAfterLast('-')?.toLongOrNull()
/** Retain the canonical trace once, without copying it across a user/system/tool boundary. */
private fun List<ChatMessage>.reconcileReasoningRows(): List<ChatMessage> =
mapIndexed { index, message ->
val previous = getOrNull(index - 1)
if (previous?.role == MessageRole.ASSISTANT && previous.content.isBlank() &&
previous.canonicalRestId != null && previous.reasoningText.isNotBlank() &&
message.role == MessageRole.ASSISTANT && message.content.isNotBlank() &&
message.canonicalRestId != null && message.reasoningText == previous.reasoningText
) {
message.copy(reasoningText = "")
} else {
message
}
}
/** Merge a REST page without matching it against already-settled transcript rows. */ /** Merge a REST page without matching it against already-settled transcript rows. */
internal fun mergeIncrementalTranscriptPage( internal fun mergeIncrementalTranscriptPage(
restMessages: List<ChatMessage>, restMessages: List<ChatMessage>,
@@ -234,7 +405,7 @@ internal fun mergeIncrementalTranscriptPage(
): List<ChatMessage> { ): List<ChatMessage> {
val settledEnd = val settledEnd =
currentMessages.indexOfLast { message -> currentMessages.indexOfLast { message ->
serverMessageIndex(message.id, sessionId)?.let { it < offset } == true serverMessageIndex(message.canonicalRestId ?: message.id, sessionId)?.let { it < offset } == true
} }
return ( return (
currentMessages.take(settledEnd + 1) + currentMessages.take(settledEnd + 1) +
@@ -45,6 +45,8 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
@@ -374,6 +376,7 @@ class ChatViewModel(
slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext), slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext),
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default, searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
private val historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = searchDispatcher,
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
constructor(application: Application) : this(application, startCleanup = true) constructor(application: Application) : this(application, startCleanup = true)
@@ -443,6 +446,15 @@ class ChatViewModel(
*/ */
private var latestPaging = false private var latestPaging = false
private var isSyncingMessages = false private var isSyncingMessages = false
private var cacheCursor: ChatPersistenceRepository.Cursor? = null
private var cacheHasOlder = false
private var cacheLoaded = false
private var serverHasOlder = false
private var cacheJob: Job? = null
private var hydrationJob: Job? = null
private var olderJob: Job? = null
private var syncJob: Job? = null
private val historyFetchMutex = Mutex()
// ── Session resume recovery (desktop parity) ──────────────────────── // ── Session resume recovery (desktop parity) ────────────────────────
// Bounded auto-retry with exponential backoff, mirroring the desktop's // Bounded auto-retry with exponential backoff, mirroring the desktop's
@@ -642,7 +654,19 @@ class ChatViewModel(
activeHydrationRequestSequence = ++hydrationRequestSequence activeHydrationRequestSequence = ++hydrationRequestSequence
// Preserve metadata so late results and errors are rejected as stale. // Preserve metadata so late results and errors are rejected as stale.
sessionGeneration++ sessionGeneration++
_uiState.update { it.copy(isSessionReady = false) } // #129: invalidate and cancel paged work together, including its busy flags.
hydrationJob?.cancel()
olderJob?.cancel()
syncJob?.cancel()
cacheJob?.cancel()
isSyncingMessages = false
cancelResumeRetry()
_uiState.update {
it.copy(isSessionReady = false, isLoadingOlder = false, isResumeRetrying = false)
}
_uiState.value.currentSessionId?.let { sessionId ->
if (!cacheLoaded) loadCachedMessages(sessionId, sessionGeneration)
}
// Fail any in-flight awaited RPCs so callers don't hang // Fail any in-flight awaited RPCs so callers don't hang
// across the disconnect (delegated to HermesWsClient, issue #526). // across the disconnect (delegated to HermesWsClient, issue #526).
wsClient.rejectAllPending() wsClient.rejectAllPending()
@@ -2904,151 +2928,201 @@ class ChatViewModel(
} }
} }
/**
* CPU work never runs in StateFlow.update: it may retry. If a WS event or another
* page changes the transcript during compute, recompute against that newer snapshot.
*/
private suspend fun mergeHistoryPage(
isCurrent: () -> Boolean,
cached: Boolean = false,
prepend: Boolean = false,
mapPage: (List<ChatMessage>) -> List<ChatMessage>,
): List<ChatMessage>? {
while (isCurrent()) {
val snapshot = _uiState.value
val computed =
withContext(historyDispatcher) {
val page = mapPage(snapshot.messages)
val merged =
if (cached) {
mergeCachedTranscriptPage(page, snapshot.messages)
} else {
mergeTranscriptWithLive(
page,
snapshot.messages,
chronological = !prepend,
preserveLiveIds = true,
)
}
val stableMessages = if (merged == snapshot.messages) snapshot.messages else merged
Triple(page, stableMessages, hydrateTodosFromMessages(merged).ifEmpty { snapshot.todos })
}
if (!isCurrent()) return null
var applied = false
_uiState.update { current ->
applied = isCurrent() && current.messages === snapshot.messages && current.todos === snapshot.todos
if (applied) current.copy(messages = computed.second, todos = computed.third) else current
}
if (applied) return computed.first
}
return null
}
private fun publishHistoryAvailability() {
_uiState.update { it.copy(hasOlderMessages = cacheHasOlder || serverHasOlder) }
}
private suspend fun readCachedPage(
sessionId: String,
generation: Long,
isCurrent: () -> Boolean = { isCurrentSessionRequest(sessionId, generation) },
): Boolean {
val before = cacheCursor
// Room suspend queries own their IO executor; entity mapping/dedup runs on Default.
val page = withContext(historyDispatcher) { repo.loadPage(sessionId, before, MESSAGE_PAGE_SIZE) }
val valid = { isCurrent() && cacheCursor == before }
if (!valid()) return false
mergeHistoryPage(valid, cached = true) { page.messages } ?: return false
if (!valid()) return false
cacheCursor = page.cursor
cacheHasOlder = page.hasOlder
cacheLoaded = true
publishHistoryAvailability()
return true
}
private fun loadCachedMessages( private fun loadCachedMessages(
sessionId: String, sessionId: String,
generation: Long, generation: Long,
): Job = ): Job {
viewModelScope.launch(ioDispatcher) { cacheJob?.cancel()
val cachedMessages = dedupeCachedMessages(repo.loadMessages(sessionId)) return viewModelScope
_uiState.update { state -> .launch {
// Only paint if still showing this session AND no fresher server try {
// page has landed yet (a fast REST fetch must not be clobbered readCachedPage(sessionId, generation)
// by a stale cache read that loses the race). } catch (e: CancellationException) {
if (isCurrentSessionRequest(sessionId, generation) && throw e
state.messages.isEmpty() && } catch (e: Exception) {
cachedMessages.isNotEmpty() if (isCurrentSessionRequest(sessionId, generation)) {
) { // The first read can be retried by the same older-history action.
state.copy( cacheHasOlder = true
messages = cachedMessages, publishHistoryAvailability()
isLoading = false, _uiState.update { it.copy(errorMessage = "Failed to load cached messages: ${e.message}") }
todos = hydrateTodosFromMessages(cachedMessages), }
) } finally {
} else { if (isCurrentSessionRequest(sessionId, generation) && _uiState.value.messages.isNotEmpty()) {
state _uiState.update { it.copy(isLoading = false) }
}
} }
} }.also { cacheJob = it }
} }
private fun loadSessionMessages( private fun loadSessionMessages(
sessionId: String, sessionId: String,
generation: Long, generation: Long,
) { ) {
if (activeHydrationRequestSequence != 0L && cacheJob?.isActive == true) {
cacheJob?.cancel()
if (!cacheLoaded) loadCachedMessages(sessionId, generation)
}
val requestSequence = ++hydrationRequestSequence val requestSequence = ++hydrationRequestSequence
activeHydrationRequestSequence = requestSequence activeHydrationRequestSequence = requestSequence
viewModelScope.launch { hydrationJob?.cancel()
// Initial page: ask for the NEWEST page directly (order=latest — olderJob?.cancel()
// offset is measured back from the newest message, page returned syncJob?.cancel()
// chronologically; verified in hermes_state.py get_messages). isSyncingMessages = false
// No count-based anchor, so a stale session-list message_count can _uiState.update { it.copy(isLoadingOlder = false) }
// no longer land the page at the wrong position (issue #859). val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
// Legacy backends without the `order` param ignore it and echo no hydrationJob =
// `pagination` — detect that and fall back to the count-based viewModelScope.launch {
// anchor so nothing regresses. try {
val latestResult = fetchMessagePage(sessionId, 0, MESSAGE_PAGE_SIZE, order = "latest") val latestResult = fetchMessagePage(sessionId, 0, MESSAGE_PAGE_SIZE, order = "latest")
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch if (!valid()) return@launch
val (result, requestedOffset) = // Backends without stable latest IDs use the legacy absolute-position protocol.
if (latestResult is NetworkResult.Success && latestResult.data.pagination?.order == "latest") { val useLatest =
latestPaging = true latestResult is NetworkResult.Success &&
latestResult to 0 latestResult.data.pagination?.order == "latest" &&
} else if (latestResult is NetworkResult.Success) { latestResult.data.messages.all { it.id != null }
latestPaging = false val (result, requestedOffset) =
val messageCount = fetchServerMessageCount(sessionId, generation, requestSequence) if (useLatest || latestResult is NetworkResult.Failure) {
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch latestResult to 0
val offset = (messageCount - MESSAGE_PAGE_SIZE).coerceAtLeast(0) } else {
fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE) to offset val count = fetchServerMessageCount(sessionId, generation, requestSequence)
} else { if (!valid()) return@launch
latestPaging = false val offset = (count - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
latestResult to 0 fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE) to offset
} }
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch if (!valid()) return@launch
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
// REST 200 — the gateway has the row for this session. val serverOffset = result.data.pagination?.offset ?: result.data.offset ?: requestedOffset
sessionHasServerPresence = true val raw = result.data.messages
val serverOffset = result.data.pagination?.offset ?: result.data.offset ?: requestedOffset val page =
val chatMessages = mergeHistoryPage(valid) { current ->
mapServerMessages( mapServerMessages(
sessionId, sessionId,
result.data.messages.orEmpty(), raw,
serverOffset, serverOffset,
latestPaging, useLatest,
_uiState.value.messages, current,
context = getApplication(), context = getApplication(),
) )
loadedMessageOffset = serverOffset } ?: return@launch
withContext(ioDispatcher) { persistHistoryPage(page, sessionId)
repo.persistMessages(chatMessages, sessionId) if (!valid()) return@launch
} latestPaging = useLatest
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch // Cursor counts RAW rows, including hidden reasoning/placeholder rows.
_uiState.update { state -> loadedMessageOffset = if (useLatest) serverOffset + raw.size else serverOffset
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update state serverHasOlder = raw.isNotEmpty() &&
// Merge, don't replace: a reload mid-turn must not if (useLatest) raw.size >= MESSAGE_PAGE_SIZE else serverOffset > 0
// drop live WS bubbles (running tool call, streaming sessionHasServerPresence = true
// answer) the server hasn't persisted yet. Issue #771. publishHistoryAvailability()
val merged = mergeTranscriptWithLive(chatMessages, state.messages) hydratedGeneration = generation
val hasOlder = finishResumeWhenHydrated(generation)
if (latestPaging) { }
// Newest-anchored: older messages exist iff the
// page came back FULL (a short page means we hit is NetworkResult.Failure -> {
// the oldest boundary). val error = result.error.message
val returned = result.data.pagination?.returned ?: chatMessages.size if (error.contains("404", ignoreCase = true) ||
returned >= MESSAGE_PAGE_SIZE && chatMessages.isNotEmpty() error.contains("not found", ignoreCase = true)
) {
serverHasOlder = false
publishHistoryAvailability()
hydratedGeneration = generation
finishResumeWhenHydrated(generation)
} else { } else {
serverOffset > 0 && chatMessages.isNotEmpty() if (hydratedGeneration == generation) hydratedGeneration = -1L
handleResumeFailure(sessionId, generation, "Failed to load messages: $error")
} }
state.copy(
messages = merged,
todos = hydrateTodosFromMessages(merged),
isLoading = false,
hasOlderMessages = hasOlder,
isLoadingOlder = false,
)
}
hydratedGeneration = generation
finishResumeWhenHydrated(generation)
}
is NetworkResult.Failure -> {
val errorMsg = result.error.message
val is404 =
errorMsg.contains("404", ignoreCase = true) ||
errorMsg.contains("not found", ignoreCase = true)
if (is404) {
// A 404 from GET /api/sessions/{id}/messages indicates the session has no
// persisted rows in state.db (e.g. newly created/lazy unprompted session, or empty history).
// It is NOT a fatal error: keep the session open with empty transcript, clear loading,
// and mark hydrated cleanly.
_uiState.update {
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it
it.copy(
isLoading = false,
isLoadingOlder = false,
hasOlderMessages = false,
)
} }
hydratedGeneration = generation
finishResumeWhenHydrated(generation)
return@launch
} }
if (hydratedGeneration == generation) hydratedGeneration = -1L } catch (e: CancellationException) {
_uiState.update { throw e
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it } catch (e: Exception) {
it.copy( if (valid()) {
isLoading = false, hydratedGeneration = -1L
isLoadingOlder = false, handleResumeFailure(sessionId, generation, "Failed to load messages: ${e.message}")
)
} }
// Route the transcript failure through the bounded resume } finally {
// retry (desktop parity) instead of a one-shot snackbar — if (valid()) _uiState.update { it.copy(isLoading = false) }
// a transient backend/network blip recovers on its own,
// and a persistent failure ends in an explicit Retry.
handleResumeFailure(
sessionId,
generation,
"Failed to load messages: ${result.error.message}",
)
} }
} }
}
private suspend fun persistHistoryPage(
page: List<ChatMessage>,
sessionId: String,
) {
// A mapped page can reuse a live WS message. Never overwrite its newer persisted
// version with the snapshot used for mapping; WS owns persistence of those IDs.
val pageIds = page.mapNotNull { it.canonicalRestId }.toSet()
val aliases = _uiState.value.messages.filter { it.restId in pageIds && !it.id.startsWith("rest-") }
withContext(historyDispatcher) {
repo.persistMessages(
page.mapNotNull { message -> message.canonicalRestId?.let { message.copy(id = it, restId = null) } },
sessionId,
)
repo.confirmIdentities(aliases, sessionId)
} }
} }
@@ -3096,6 +3170,15 @@ class ChatViewModel(
runtimeSessionId = null runtimeSessionId = null
pendingInitialPrompt = null pendingInitialPrompt = null
ActiveSessionHolder.clear() ActiveSessionHolder.clear()
cacheJob?.cancel()
hydrationJob?.cancel()
olderJob?.cancel()
syncJob?.cancel()
activeHydrationRequestSequence = 0L
cacheCursor = null
cacheHasOlder = false
cacheLoaded = false
serverHasOlder = false
loadedMessageOffset = 0 loadedMessageOffset = 0
latestPaging = false latestPaging = false
isSyncingMessages = false isSyncingMessages = false
@@ -3372,222 +3455,150 @@ class ChatViewModel(
fun loadOlderMessages() { fun loadOlderMessages() {
val state = _uiState.value val state = _uiState.value
val sessionId = state.currentSessionId ?: return val sessionId = state.currentSessionId ?: return
if (!state.hasOlderMessages || state.isLoadingOlder || olderJob?.isActive == true) return
if (cacheJob?.isActive == true) return
val fromCache = cacheHasOlder || !cacheLoaded
if (!fromCache && (hydrationJob?.isActive == true || isSyncingMessages || !serverHasOlder)) return
val generation = sessionGeneration val generation = sessionGeneration
if (!state.hasOlderMessages || state.isLoadingOlder) return val requestSequence = activeHydrationRequestSequence
if (!latestPaging && loadedMessageOffset <= 0) return val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
val oldOffset = loadedMessageOffset
// latest: offsets count BACK from the newest message, so older pages go
// UP; legacy: absolute offsets go DOWN toward 0 (issue #859).
val newOffset =
if (latestPaging) {
oldOffset + MESSAGE_PAGE_SIZE
} else {
(oldOffset - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
}
// Legacy: the final page can be short; requesting a full page at a
// clamped offset OVERLAPS already-loaded rows (duplicate stable keys
// crash LazyColumn), so size the request to the real gap. Latest:
// pages are disjoint from-end ranges, so a full page never overlaps.
val limit = if (latestPaging) MESSAGE_PAGE_SIZE else oldOffset - newOffset
_uiState.update { it.copy(isLoadingOlder = true) } _uiState.update { it.copy(isLoadingOlder = true) }
viewModelScope.launch { olderJob =
val result = viewModelScope.launch {
fetchMessagePage( try {
sessionId, while (cacheHasOlder || !cacheLoaded) {
newOffset, val beforeMessages = _uiState.value.messages
limit, if (!readCachedPage(sessionId, generation, valid)) return@launch
order = if (latestPaging) "latest" else null, val afterMessages = _uiState.value.messages
) val addedRows =
when (result) { withContext(historyDispatcher) {
is NetworkResult.Success -> { val previousIds = beforeMessages.mapTo(HashSet()) { it.id }
if (!isCurrentSessionRequest(sessionId, generation)) return@launch afterMessages.any { it.id !in previousIds }
val returnedOffset = result.data.pagination?.offset ?: result.data.offset ?: newOffset
val older =
mapServerMessages(
sessionId,
result.data.messages.orEmpty(),
returnedOffset,
latestPaging,
_uiState.value.messages,
isPagingOlder = true,
)
loadedMessageOffset = returnedOffset
withContext(ioDispatcher) { repo.persistMessages(older, sessionId) }
_uiState.update { current ->
if (!isCurrentSessionRequest(sessionId, generation)) return@update current
val hasOlder =
if (latestPaging) {
// Full page = more older messages behind it; a
// short (or empty) page is the oldest boundary.
val returned = result.data.pagination?.returned ?: older.size
returned >= limit && older.isNotEmpty()
} else {
returnedOffset < oldOffset && older.isNotEmpty() && returnedOffset > 0
} }
current.copy( if (!valid()) return@launch
messages = (older + current.messages).distinctBy { it.id }, if (addedRows) return@launch
isLoadingOlder = false, // Hydration persists canonical rows behind the local cache cursor. Consume
hasOlderMessages = hasOlder, // those already-visible echoes without spending another older-history action.
)
} }
} if (hydrationJob?.isActive == true || isSyncingMessages || !serverHasOlder) return@launch
// Cache reads may suspend through hydration/sync; use the current server cursor.
val useLatest = latestPaging
val oldOffset = loadedMessageOffset
val offset = if (useLatest) oldOffset else (oldOffset - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
val limit = if (useLatest) MESSAGE_PAGE_SIZE else oldOffset - offset
if (limit <= 0) return@launch
val result = fetchMessagePage(sessionId, offset, limit, order = if (useLatest) "latest" else null)
if (!valid()) return@launch
when (result) {
is NetworkResult.Success -> {
val returnedOffset = result.data.pagination?.offset ?: result.data.offset ?: offset
val raw = result.data.messages
val page =
mergeHistoryPage(valid, prepend = true) { current ->
mapServerMessages(
sessionId,
raw,
returnedOffset,
useLatest,
current,
isPagingOlder = true,
context = getApplication(),
)
} ?: return@launch
persistHistoryPage(page, sessionId)
if (!valid()) return@launch
loadedMessageOffset = if (useLatest) returnedOffset + raw.size else returnedOffset
serverHasOlder = raw.isNotEmpty() &&
if (useLatest) {
loadedMessageOffset > oldOffset && raw.size >= limit
} else {
returnedOffset < oldOffset && returnedOffset > 0
}
publishHistoryAvailability()
}
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
if (isCurrentSessionRequest(sessionId, generation)) { it.copy(
it.copy(isLoadingOlder = false) errorMessage = "Failed to load older messages: ${result.error.message}",
} else { )
it }
} }
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (valid()) {
_uiState.update {
it.copy(
errorMessage = "Failed to load older messages: ${e.message}",
)
}
}
} finally {
if (valid()) _uiState.update { it.copy(isLoadingOlder = false) }
} }
} }
}
} }
fun syncCurrentSession() { fun syncCurrentSession() {
// Issue #840: a session created but never prompted has no server row
// yet — the REST transcript 404s and the resume-retry machinery spins
// forever (visible in logcat as repeated /messages 404s). MessageStart
// flips the flag once the first prompt persists the row.
if (!sessionHasServerPresence) return if (!sessionHasServerPresence) return
val state = _uiState.value val state = _uiState.value
val sessionId = state.currentSessionId ?: return val sessionId = state.currentSessionId ?: return
val generation = sessionGeneration if (isSyncingMessages || hydrationJob?.isActive == true || state.isLoading || state.isLoadingOlder ||
if (isSyncingMessages || state.isLoading || state.isLoadingOlder || state.isAgentTyping || state.isAgentTyping || _streamingState.value.streamingMessage != null
_streamingState.value.streamingMessage != null
) { ) {
return return
} }
val generation = sessionGeneration
val requestSequence = activeHydrationRequestSequence
val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
val useLatest = latestPaging
val nextOffset = val nextOffset =
if (latestPaging) { if (useLatest) {
// Newest-anchored paging can't compute an absolute
// "after my last row" offset (from-end offsets shift as the
// transcript grows), so refetch the newest page; the logical
// merge below keeps existing copies and only adds new rows
// (issue #859).
0 0
} else { } else {
state.messages state.messages
.mapNotNull { serverMessageIndex(it.id, sessionId) } .mapNotNull { serverMessageIndex(it.canonicalRestId ?: it.id, sessionId) }
.maxOrNull() .maxOrNull()
?.plus(1) ?.plus(1)
?: loadedMessageOffset ?: loadedMessageOffset
} }
isSyncingMessages = true isSyncingMessages = true
viewModelScope.launch { syncJob =
try { viewModelScope.launch {
val result = try {
fetchMessagePage( val result =
sessionId, fetchMessagePage(sessionId, nextOffset, MESSAGE_PAGE_SIZE, if (useLatest) "latest" else null)
nextOffset, if (!valid()) return@launch
MESSAGE_PAGE_SIZE, if (result is NetworkResult.Success) {
order = if (latestPaging) "latest" else null, val offset = result.data.pagination?.offset ?: result.data.offset ?: nextOffset
) val page =
when (result) { mergeHistoryPage(valid) { current ->
is NetworkResult.Success -> { mapServerMessages(
if (!isCurrentSessionRequest(sessionId, generation)) return@launch sessionId,
val incoming = result.data.messages,
mapServerMessages( offset,
sessionId, useLatest,
result.data.messages.orEmpty(), current,
nextOffset, // Sync fetches recent replies, so confirm live completion identities.
latestPaging, isPagingOlder = false,
_uiState.value.messages, context = getApplication(),
isPagingOlder = true, )
) } ?: return@launch
if (incoming.isEmpty()) return@launch persistHistoryPage(page, sessionId)
withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) } // Never derive the older cursor from displayed rows or reset it to this latest page.
_uiState.update { current -> // Append-only growth shifts from-end offsets toward newer rows: the next older
if (!isCurrentSessionRequest(sessionId, generation)) return@update current // request may overlap, but stable IDs remove echoes without skipping any history.
// Issue #771: the sync merge was dropping the
// newest tool bubble — the incoming REST page
// didn't include it yet (server persists tool rows
// at completion, but the sync offset may predate
// that), and the fragile toolName/content match
// consumed the WRONG incoming tool for an existing
// one, leaving the newest WS tool with no match
// → dropped. Use sameLogicalMessage (canonical
// result-key match) and always preserve any WS
// message that has no REST counterpart.
val unmatchedIncoming: MutableList<ChatMessage?> = incoming.toMutableList()
val incomingById = HashMap<String, Int>(unmatchedIncoming.size)
for (i in unmatchedIncoming.indices) {
val id = unmatchedIncoming[i]?.id
if (id != null && !incomingById.containsKey(id)) {
incomingById[id] = i
}
}
val mergedList = mutableListOf<ChatMessage>()
for (existing in current.messages) {
val existingServerIndex = serverMessageIndex(existing.id, sessionId)
if (existingServerIndex != null) {
val matchIdx = incomingById[existing.id]
if (matchIdx != null && unmatchedIncoming[matchIdx] != null) {
mergedList.add(unmatchedIncoming[matchIdx]!!)
unmatchedIncoming[matchIdx] = null
} else if (latestPaging) {
// Newest-anchored paging re-keys rows by
// from-end position, so a transcript that
// grew since the last fetch shifted ids —
// match by logical content and keep the
// existing copy (issue #859).
val logicalIdx =
unmatchedIncoming.indexOfFirst { inc ->
inc != null && sameLogicalMessage(inc, existing)
}
if (logicalIdx >= 0) {
mergedList.add(existing)
unmatchedIncoming[logicalIdx] = null
} else {
mergedList.add(existing)
}
} else {
mergedList.add(existing)
}
} else {
// WS message (UUID id, no server index):
// match by canonical content, not fragile
// toolName/content equality.
val matchIdx =
unmatchedIncoming.indexOfFirst { inc ->
inc != null && sameLogicalMessage(inc, existing)
}
if (matchIdx >= 0) {
// Prefer the WS copy (richer payload,
// real tool name) when available.
mergedList.add(existing)
unmatchedIncoming[matchIdx] = null
} else {
// No REST counterpart (server hasn't
// persisted yet) — KEEP the WS message.
mergedList.add(existing)
}
}
}
for (inc in unmatchedIncoming) {
if (inc != null) {
mergedList.add(inc)
}
}
val merged = mergedList.distinctBy { it.id }
if (sameMessages(current.messages, merged)) {
current
} else {
current.copy(messages = merged)
}
}
} }
} catch (e: CancellationException) {
is NetworkResult.Failure -> {} throw e
} catch (e: Exception) {
if (valid()) _uiState.update { it.copy(errorMessage = "Failed to sync messages: ${e.message}") }
} finally {
if (valid()) isSyncingMessages = false
} }
} finally {
if (generation == sessionGeneration) isSyncingMessages = false
} }
}
} }
internal fun onModelSwitchInitiated() { internal fun onModelSwitchInitiated() {
@@ -3926,15 +3937,17 @@ class ChatViewModel(
offset: Int, offset: Int,
limit: Int, limit: Int,
order: String? = null, order: String? = null,
) = withContext(ioDispatcher) { ) = historyFetchMutex.withLock {
safeApiCall { withContext(ioDispatcher) {
ApiClient.hermesApi.getSessionMessages( safeApiCall {
sessionId = sessionId, ApiClient.hermesApi.getSessionMessages(
limit = limit, sessionId = sessionId,
offset = offset, limit = limit,
includeCompacted = true, offset = offset,
order = order, includeCompacted = true,
) order = order,
)
}
} }
} }
@@ -0,0 +1,29 @@
package com.m57.hermescontrol.ui.chat.components
/** One older-page request per deliberate upward gesture, including an overscroll at index zero. */
internal class ChatHistoryPrefetch {
private var upwardPixels = 0f
private var requested = false
fun onScroll(
firstVisibleIndex: Int,
deltaY: Float,
userInput: Boolean,
canLoad: Boolean,
): Boolean {
if (userInput && deltaY < 0f) upwardPixels = 0f
if (deltaY <= 0f || !canLoad || requested) return false
// A fling retains the drag's intent until scrolling stops. Programmatic
// scrolling without an upward user gesture must never load history.
if (!userInput && upwardPixels == 0f) return false
upwardPixels += deltaY
if (firstVisibleIndex > 25 || upwardPixels < 24f) return false
requested = true
return true
}
fun endGesture() {
upwardPixels = 0f
requested = false
}
}
@@ -33,9 +33,9 @@ import kotlinx.coroutines.yield
* 5. **Unread indicator** — when follow is paused, [pendingCount] accumulates the * 5. **Unread indicator** — when follow is paused, [pendingCount] accumulates the
* number of tail updates; shows on the FAB. Tapping the FAB resumes following * number of tail updates; shows on the FAB. Tapping the FAB resumes following
* and clears the count. * and clears the count.
* 6. **Paging anchor preservation** — capture the first visible item + offset * 6. **Paging anchors** — the full-bleed renderer preserves the live lazy row
* with [captureAnchor] before prepending older history, then * key and offset when older history is inserted; this controller owns no
* [restoreAnchor] after insertion so the same content stays under the eye. * fetch-time anchor or animated compensation.
* 7. **Serialized scroll commands** — every scroll (send, FAB, session switch, * 7. **Serialized scroll commands** — every scroll (send, FAB, session switch,
* search navigation, auto-follow) is launched from [scope] so animations * search navigation, auto-follow) is launched from [scope] so animations
* don't compete. * don't compete.
@@ -189,30 +189,17 @@ class ChatScrollController(
} }
} }
/** An explicit history gesture must not be undone by a short list's bottom-follow. */
fun pauseFollowing() {
isFollowingBottom = false
}
/** FAB tap: resume following + clear unread. */ /** FAB tap: resume following + clear unread. */
fun resumeFollowing() = jumpToBottom(animated = true) fun resumeFollowing() = jumpToBottom(animated = true)
/** True when the FAB should be visible (not following bottom + content exists). */ /** True when the FAB should be visible (not following bottom + content exists). */
fun showFab(contentPresent: Boolean): Boolean = !isFollowingBottom && contentPresent fun showFab(contentPresent: Boolean): Boolean = !isFollowingBottom && contentPresent
/**
* Current pixel scroll offset of the first visible item — captured before
* prepending older history so it can be restored after insertion.
*/
fun captureAnchorOffset(): Int = listState.firstVisibleItemScrollOffset
/**
* Restore the viewport to a specific item (resolved by id in the caller, so
* the restore is robust even if streaming tokens arrive during the page
* insert). Keeps the reader's eye on the same content after prepend.
*/
fun scrollToItem(
index: Int,
offset: Int,
) {
scope.launch { listState.scrollToItem(index, offset) }
}
/** /**
* Navigate to a search match (serialized through [scope]). * Navigate to a search match (serialized through [scope]).
* *
@@ -258,7 +245,7 @@ fun tailContentKey(
clarifyRequest: Any?, clarifyRequest: Any?,
): Any = ): Any =
listOf( listOf(
messages.size, messages.lastOrNull(),
streamingMessage?.hashCode() ?: 0, streamingMessage?.hashCode() ?: 0,
isThinking, isThinking,
subagentIndicators.size, subagentIndicators.size,
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -12,9 +11,17 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
@@ -32,6 +39,7 @@ import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch
import com.m57.hermescontrol.ui.chat.components.ChatScrollController import com.m57.hermescontrol.ui.chat.components.ChatScrollController
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
import com.m57.hermescontrol.ui.chat.components.ReasoningCard import com.m57.hermescontrol.ui.chat.components.ReasoningCard
@@ -93,6 +101,9 @@ fun FullBleedChatList(
savingAttachmentPath: String? = null, savingAttachmentPath: String? = null,
openingAttachmentPath: String? = null, openingAttachmentPath: String? = null,
onImageClick: (ImageViewerModel) -> Unit = {}, onImageClick: (ImageViewerModel) -> Unit = {},
hasOlderMessages: Boolean = false,
pagingSessionId: String? = null,
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
) { ) {
if (messages.isEmpty() && !isLoading && !isAgentTyping) { if (messages.isEmpty() && !isLoading && !isAgentTyping) {
Box( Box(
@@ -105,19 +116,133 @@ fun FullBleedChatList(
) )
} }
} else { } else {
val toolMilestones = remember(messages) { toolCallMilestones(messages) } // Keep only the incoming older prefix out of layout during a drag/fling.
// The input remains authoritative: overlap updates, new messages and streaming
// stay live, and a session change resets the boundary immediately.
val previousFirstId = remember(listState, pagingSessionId) { mutableStateOf(messages.firstOrNull()?.id) }
val firstRenderedId = previousFirstId.value
val olderPrefixSize =
remember(messages, firstRenderedId) {
if (firstRenderedId != null && firstRenderedId != messages.firstOrNull()?.id) {
messages.indexOfFirst { it.id == firstRenderedId }.coerceAtLeast(0)
} else {
0
}
}
val hiddenPrefixSize = if (olderPrefixSize > 0 && listState.isScrollInProgress) olderPrefixSize else 0
val renderedMessages =
remember(messages, hiddenPrefixSize) {
if (hiddenPrefixSize > 0) messages.subList(hiddenPrefixSize, messages.size) else messages
}
val toolMilestones = remember(renderedMessages) { toolCallMilestones(renderedMessages) }
val settledTurns = remember(renderedMessages) { groupIntoTurns(renderedMessages) }
val settledIds = remember(renderedMessages) { renderedMessages.mapTo(HashSet()) { it.id } }
val turns = val turns =
remember(messages, streamingState.streamingMessage) { remember(settledTurns, streamingState.streamingMessage) {
groupIntoTurnsWithStreaming(messages, streamingState.streamingMessage) appendStreamingTurn(settledTurns, settledIds, streamingState.streamingMessage)
} }
val agentStatus = deriveAgentStatus(isAgentTyping, streamingState, messages) val agentStatus = deriveAgentStatus(isAgentTyping, streamingState, renderedMessages)
// messageId → LazyColumn item index. The lazy list has EXTRA items // A single ordered definition supplies both emitted tail rows and anchor keys.
// vs the message list (reasoning hoists, tool rows), so search-match val tailItems =
// scrolling must resolve the lazy index, not use the message index. buildMap<String, @Composable () -> Unit> {
val lazyIndexById = agentStatus?.let { status ->
remember(turns, isLoadingOlder) { put("agent_status") {
messageIdToLazyIndex(turns, leadingItems = if (isLoadingOlder) 1 else 0) AgentStatusIndicator(status = status)
}
}
// Clarify bubble — rendered at the very bottom
if (clarifyRequest != null) {
put("clarify_bubble") {
ClarifyBubble(
clarifyRequest = clarifyRequest,
onRespondSingle = { option -> onRespondClarify?.invoke(option) },
onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) },
onDismiss = { onDismissClarify?.invoke() },
)
}
}
if (vaultUnlockPrompt != null) {
put("vault_unlock_card") {
VaultUnlockCard(
prompt = vaultUnlockPrompt,
onConfirm = { password -> onRespondVaultUnlock?.invoke(password) },
onDismiss = { onDismissVaultUnlock?.invoke() },
)
}
}
if (vaultSaveLoginPrompt != null) {
put("vault_save_login_card") {
VaultSaveLoginCard(
prompt = vaultSaveLoginPrompt,
onConfirm = { identifier, password ->
onRespondVaultSaveLogin?.invoke(identifier, password)
},
onDismiss = { onDismissVaultSaveLogin?.invoke() },
)
}
}
if (vaultCodePrompt != null) {
put("vault_code_card") {
VaultCodeCard(
prompt = vaultCodePrompt,
onConfirm = { code -> onRespondVaultCode?.invoke(code) },
onDismiss = { onDismissVaultCode?.invoke() },
)
}
}
} }
// custom23 prepend regression: a 150-row page can move the anchor beyond
// Compose's nearest-key lookup window. Capture the LIVE layout at insertion,
// not when fetching starts, and request its new index before the next measure.
// A staged prefix only reaches here at idle, so this cannot stop its drag/fling.
SideEffect {
val oldFirstId = previousFirstId.value
val firstId = renderedMessages.firstOrNull()?.id
if (oldFirstId != null && oldFirstId != firstId && renderedMessages.any { it.id == oldFirstId }) {
val firstVisibleIndex = listState.firstVisibleItemIndex
val anchor = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == firstVisibleIndex }
if (anchor != null) {
val newIndex = (fullBleedItemKeys(turns) + tailItems.keys).indexOf(anchor.key)
if (newIndex >= 0 && newIndex != anchor.index) {
listState.requestScrollToItem(newIndex, listState.firstVisibleItemScrollOffset)
}
}
}
previousFirstId.value = firstId
}
val prefetch = remember(pagingSessionId) { ChatHistoryPrefetch() }
val canLoad = rememberUpdatedState(hasOlderMessages && !isLoadingOlder && !isLoading)
val loadOlder = rememberUpdatedState(onLoadOlder)
val prefetchConnection =
remember(listState, prefetch, scrollController) {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
if (prefetch.onScroll(
firstVisibleIndex = listState.firstVisibleItemIndex,
deltaY = available.y,
userInput = source == NestedScrollSource.UserInput,
canLoad = canLoad.value,
)
) {
scrollController.pauseFollowing()
loadOlder.value()
}
return Offset.Zero
}
}
}
LaunchedEffect(listState, prefetch) {
snapshotFlow { listState.isScrollInProgress }.collect { scrolling ->
if (!scrolling) prefetch.endGesture()
}
}
// Scroll the current search match into view, word-focused. Lives here // Scroll the current search match into view, word-focused. Lives here
// (not in ChatLifecycleEffects) because only this composable knows // (not in ChatLifecycleEffects) because only this composable knows
@@ -128,6 +253,7 @@ fun FullBleedChatList(
searchState.currentIndex, searchState.currentIndex,
searchState.matchIndices, searchState.matchIndices,
searchState.matchOffsets, searchState.matchOffsets,
renderedMessages.firstOrNull()?.id,
) { ) {
if (searchState.isActive && if (searchState.isActive &&
searchState.currentIndex >= 0 && searchState.currentIndex >= 0 &&
@@ -135,6 +261,9 @@ fun FullBleedChatList(
) { ) {
val messageIndex = searchState.matchIndices[searchState.currentIndex] val messageIndex = searchState.matchIndices[searchState.currentIndex]
if (messageIndex < 0 || messageIndex >= messages.size) return@LaunchedEffect if (messageIndex < 0 || messageIndex >= messages.size) return@LaunchedEffect
// Search indices refer to incoming messages; resolve by id into the
// rendered rows. A hit in a staged prefix is retried when it becomes visible.
val lazyIndexById = messageIdToLazyIndex(turns)
val lazyIndex = lazyIndexById[messages[messageIndex].id] ?: return@LaunchedEffect val lazyIndex = lazyIndexById[messages[messageIndex].id] ?: return@LaunchedEffect
val contentOffset = searchState.matchOffsets.getOrElse(searchState.currentIndex) { 0 } val contentOffset = searchState.matchOffsets.getOrElse(searchState.currentIndex) { 0 }
val contentLength = messages[messageIndex].content.length val contentLength = messages[messageIndex].content.length
@@ -153,202 +282,163 @@ fun FullBleedChatList(
} }
CompositionLocalProvider(LocalDensity provides chatDensity) { CompositionLocalProvider(LocalDensity provides chatDensity) {
LazyColumn( Box(Modifier.fillMaxSize()) {
state = listState, LazyColumn(
modifier = Modifier.fillMaxSize(), state = listState,
contentPadding = PaddingValues(vertical = 8.dp), modifier = Modifier.fillMaxSize().nestedScroll(prefetchConnection),
) { contentPadding = PaddingValues(vertical = 8.dp),
if (isLoadingOlder) { ) {
item(key = "loading-older") { turns.forEach { turn ->
Box( when (turn) {
modifier = Modifier.fillMaxWidth().padding(12.dp), is ChatTurn.User -> {
contentAlignment = Alignment.Center, // Eager captures: item lambda reads these at
) { // composition time (lazy), so capture now.
CircularProgressIndicator(modifier = Modifier.size(24.dp)) val userMessage = turn.message
} item(key = "user-${userMessage.id}") {
} Column(modifier = Modifier.padding(bottom = 12.dp)) {
} renderChatBubble(
message = userMessage,
turns.forEach { turn -> searchQuery = if (searchState.isActive) searchState.query else "",
when (turn) { isCurrentMatch =
is ChatTurn.User -> { searchState.currentMatchId != null &&
// Eager captures: item lambda reads these at searchState.currentMatchId == userMessage.id,
// composition time (lazy), so capture now. onOpenAttachment = viewModel::openAttachment,
val userMessage = turn.message onSaveAttachment = onSaveAttachment,
item(key = "user-${userMessage.id}") { savingAttachmentPath = savingAttachmentPath,
Column(modifier = Modifier.padding(bottom = 12.dp)) { openingAttachmentPath = openingAttachmentPath,
renderChatBubble( onImageClick = onImageClick,
message = userMessage, messageStatsEnabled = messageStatsEnabled,
searchQuery = if (searchState.isActive) searchState.query else "", showUserMessageTokens = showUserMessageTokens,
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == userMessage.id,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
onImageClick = onImageClick,
messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens,
)
}
}
}
is ChatTurn.Agent -> {
// Reasoning hoist: the turn's reasoning renders at the
// TOP of the turn — above tool rows — so thinking
// leads, then the tool work, then the answer. The
// matching prose entry renders without its own card.
val turnReasoning =
turn.entries
.filterIsInstance<AgentEntry.Prose>()
.firstOrNull { it.message.reasoningText.isNotBlank() }
if (turnReasoning != null) {
val reasoning = turnReasoning.message
item(key = "reasoning-${reasoning.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
ReasoningCard(
reasoningText = reasoning.reasoningText,
isStreaming = reasoning.isStreaming,
) )
} }
} }
} }
turn.entries.forEach { entry ->
when (entry) { is ChatTurn.Agent -> {
is AgentEntry.Prose -> { // Reasoning hoist: the turn's reasoning renders at the
val proseMessage = entry.message // TOP of the turn — above tool rows — so thinking
val hoistedReasoning = // leads, then the tool work, then the answer. The
turnReasoning != null && // matching prose entry renders without its own card.
proseMessage.id == turnReasoning.message.id val turnReasoning =
if (proseMessage.hasVisibleAgentContent()) { turn.entries
item(key = "prose-${proseMessage.id}") { .filterIsInstance<AgentEntry.Prose>()
Column(modifier = Modifier.padding(bottom = 12.dp)) { .firstOrNull { it.message.reasoningText.isNotBlank() }
if (proseMessage.isStreaming && typingEffectEnabled) { if (turnReasoning != null) {
StreamingFullBleedWithTypingEffect( val reasoning = turnReasoning.message
streaming = proseMessage, item(key = "reasoning-${reasoning.id}") {
typingDelayMs = typingEffectDelayMs, Column(modifier = Modifier.padding(bottom = 6.dp)) {
showReasoning = !hoistedReasoning, ReasoningCard(
) reasoningText = reasoning.reasoningText,
isStreaming = reasoning.isStreaming,
)
}
}
}
turn.entries.forEach { entry ->
when (entry) {
is AgentEntry.Prose -> {
val proseMessage = entry.message
val hoistedReasoning =
turnReasoning != null &&
proseMessage.id == turnReasoning.message.id
if (!proseMessage.hasVisibleAgentContent() && !hoistedReasoning &&
proseMessage.reasoningText.isNotBlank()
) {
item(key = "reasoning-${proseMessage.id}") {
ReasoningCard(
reasoningText = proseMessage.reasoningText,
isStreaming = proseMessage.isStreaming,
)
}
}
if (proseMessage.hasVisibleAgentContent()) {
item(key = "prose-${proseMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
if (proseMessage.isStreaming && typingEffectEnabled) {
StreamingFullBleedWithTypingEffect(
streaming = proseMessage,
typingDelayMs = typingEffectDelayMs,
showReasoning = !hoistedReasoning,
)
} else {
FullBleedAgentMessage(
message = proseMessage,
// Highlight only bubbles that actually contain a match —
// the rest skip the highlight scan entirely.
searchQuery =
if (searchState.isActive &&
proseMessage.id in searchState.matchedIds
) {
searchState.query
} else {
""
},
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == proseMessage.id,
showReasoning = !hoistedReasoning,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
messageStatsEnabled = messageStatsEnabled,
showAssistantMessageTokens = showAssistantMessageTokens,
showTokensPerSecond = showTokensPerSecond,
)
}
}
}
}
}
is AgentEntry.ToolRow -> {
val toolMessage = entry.message
val milestone = toolMilestones[toolMessage.id]
item(key = "tool-${toolMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
FullBleedToolRow(toolMessage)
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
}
}
}
is AgentEntry.SystemEvent -> {
val sysMessage = entry.message
item(key = "sys-${sysMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
if (sysMessage.displayKind != null) {
// Timeline marker (issue #904):
// model/personality switches and
// auto-continues render as a chip.
TimelineMarkerChip(message = sysMessage)
} else { } else {
FullBleedAgentMessage( FullBleedSystemEvent(
message = proseMessage, message = sysMessage,
// Highlight only bubbles that actually contain a match — onRespondApproval = viewModel::respondToApproval,
// the rest skip the highlight scan entirely.
searchQuery =
if (searchState.isActive &&
proseMessage.id in searchState.matchedIds
) {
searchState.query
} else {
""
},
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == proseMessage.id,
showReasoning = !hoistedReasoning,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
messageStatsEnabled = messageStatsEnabled,
showAssistantMessageTokens = showAssistantMessageTokens,
showTokensPerSecond = showTokensPerSecond,
) )
} }
} }
} }
} }
} }
is AgentEntry.ToolRow -> {
val toolMessage = entry.message
val milestone = toolMilestones[toolMessage.id]
item(key = "tool-${toolMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
FullBleedToolRow(toolMessage)
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
}
}
}
is AgentEntry.SystemEvent -> {
val sysMessage = entry.message
item(key = "sys-${sysMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
if (sysMessage.displayKind != null) {
// Timeline marker (issue #904):
// model/personality switches and
// auto-continues render as a chip.
TimelineMarkerChip(message = sysMessage)
} else {
FullBleedSystemEvent(
message = sysMessage,
onRespondApproval = viewModel::respondToApproval,
)
}
}
}
}
} }
} }
} }
} }
}
agentStatus?.let { status -> tailItems.forEach { (key, content) ->
item(key = "agent_status") { item(key = key) { content() }
AgentStatusIndicator(status = status)
} }
} }
// Loading history must never become the list's first visible anchor.
// Clarify bubble — rendered at the very bottom if (isLoadingOlder) {
if (clarifyRequest != null) { CircularProgressIndicator(
item(key = "clarify_bubble") { modifier = Modifier.align(Alignment.TopCenter).padding(12.dp).size(24.dp),
ClarifyBubble( )
clarifyRequest = clarifyRequest,
onRespondSingle = { option -> onRespondClarify?.invoke(option) },
onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) },
onDismiss = { onDismissClarify?.invoke() },
)
}
}
if (vaultUnlockPrompt != null) {
item(key = "vault_unlock_card") {
VaultUnlockCard(
prompt = vaultUnlockPrompt,
onConfirm = { password -> onRespondVaultUnlock?.invoke(password) },
onDismiss = { onDismissVaultUnlock?.invoke() },
)
}
}
if (vaultSaveLoginPrompt != null) {
item(key = "vault_save_login_card") {
VaultSaveLoginCard(
prompt = vaultSaveLoginPrompt,
onConfirm = { identifier, password ->
onRespondVaultSaveLogin?.invoke(identifier, password)
},
onDismiss = { onDismissVaultSaveLogin?.invoke() },
)
}
}
if (vaultCodePrompt != null) {
item(key = "vault_code_card") {
VaultCodeCard(
prompt = vaultCodePrompt,
onConfirm = { code -> onRespondVaultCode?.invoke(code) },
onDismiss = { onDismissVaultCode?.invoke() },
)
}
} }
} }
} }
@@ -128,19 +128,22 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
fun groupIntoTurnsWithStreaming( fun groupIntoTurnsWithStreaming(
messages: List<ChatMessage>, messages: List<ChatMessage>,
streamingMessage: ChatMessage?, streamingMessage: ChatMessage?,
): List<ChatTurn> = appendStreamingTurn(groupIntoTurns(messages), messages.mapTo(HashSet()) { it.id }, streamingMessage)
/** Reuse every settled turn; only the last agent turn needs a changing streaming entry. */
internal fun appendStreamingTurn(
settled: List<ChatTurn>,
settledIds: Set<String>,
streamingMessage: ChatMessage?,
): List<ChatTurn> { ): List<ChatTurn> {
if (streamingMessage == null || messages.any { it.id == streamingMessage.id }) { if (streamingMessage == null || streamingMessage.id in settledIds) return settled
return groupIntoTurns(messages)
}
val turns = groupIntoTurns(messages).toMutableList()
val prose = AgentEntry.Prose(streamingMessage) val prose = AgentEntry.Prose(streamingMessage)
val last = turns.lastOrNull() val last = settled.lastOrNull()
if (last is ChatTurn.Agent) { return if (last is ChatTurn.Agent) {
turns[turns.lastIndex] = ChatTurn.Agent(last.entries + prose) settled.dropLast(1) + ChatTurn.Agent(last.entries + prose)
} else { } else {
turns += ChatTurn.Agent(listOf(prose)) settled + ChatTurn.Agent(listOf(prose))
} }
return turns
} }
/** /**
@@ -156,8 +159,9 @@ fun groupIntoTurnsWithStreaming(
* *
* Mirrors the item emission order in [FullBleedChatList] exactly: * Mirrors the item emission order in [FullBleedChatList] exactly:
* user turn → 1 item; agent turn → optional reasoning item, then one item per * user turn → 1 item; agent turn → optional reasoning item, then one item per
* visible entry. [leadingItems] accounts for fixed items emitted before the * visible entry, including later reasoning-only rows at their own positions.
* turns (e.g. the `loading-older` spinner when paging). * [leadingItems] accounts for fixed items emitted before the
* turns. The history spinner is an overlay and consumes no index.
* *
* @return messageId → LazyColumn item index of the message's content item * @return messageId → LazyColumn item index of the message's content item
* (prose items for agent messages; user items for user messages). * (prose items for agent messages; user items for user messages).
@@ -165,48 +169,54 @@ fun groupIntoTurnsWithStreaming(
fun messageIdToLazyIndex( fun messageIdToLazyIndex(
turns: List<ChatTurn>, turns: List<ChatTurn>,
leadingItems: Int = 0, leadingItems: Int = 0,
): Map<String, Int> { ): Map<String, Int> =
val map = mutableMapOf<String, Int>() fullBleedItemKeys(turns)
var itemIndex = leadingItems .mapIndexedNotNull { index, key ->
turns.forEach { turn -> if (key.startsWith("user-") || key.startsWith("prose-")) {
when (turn) { key.substringAfter('-') to index + leadingItems
is ChatTurn.User -> { } else {
map[turn.message.id] = itemIndex null
itemIndex++
} }
}.toMap()
is ChatTurn.Agent -> { /** Lazy row identities, including hoisted reasoning and grouped tool/system entries. */
val hasReasoning = internal fun fullBleedItemKeys(turns: List<ChatTurn>): List<String> =
turn.entries buildList {
.filterIsInstance<AgentEntry.Prose>() turns.forEach { turn ->
.any { it.message.reasoningText.isNotBlank() } when (turn) {
if (hasReasoning) { is ChatTurn.User -> {
// The reasoning-<id> hoist item occupies one slot. add("user-${turn.message.id}")
itemIndex++
} }
turn.entries.forEach { entry ->
when (entry) { is ChatTurn.Agent -> {
is AgentEntry.Prose -> { val hoisted =
if (entry.message.hasVisibleAgentContent()) { turn.entries
map[entry.message.id] = itemIndex .filterIsInstance<AgentEntry.Prose>()
itemIndex++ .firstOrNull { it.message.reasoningText.isNotBlank() }
hoisted?.let { add("reasoning-${it.message.id}") }
turn.entries.forEach { entry ->
when (entry) {
is AgentEntry.Prose -> {
if (entry.message.hasVisibleAgentContent()) {
add("prose-${entry.message.id}")
} else if (entry != hoisted && entry.message.reasoningText.isNotBlank()) {
add("reasoning-${entry.message.id}")
}
} }
}
is AgentEntry.ToolRow -> { is AgentEntry.ToolRow -> {
itemIndex++ add("tool-${entry.message.id}")
} }
is AgentEntry.SystemEvent -> { is AgentEntry.SystemEvent -> {
itemIndex++ add("sys-${entry.message.id}")
}
} }
} }
} }
} }
} }
} }
return map
}
/** /**
* Resolve the message id of the CURRENT search match once, so per-item * Resolve the message id of the CURRENT search match once, so per-item
@@ -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()) 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 @Before
fun setup() { fun setup() {
dao = FakeChatMessageDao() dao = FakeChatMessageDao()
@@ -146,4 +176,123 @@ class ChatPersistenceRepositoryTest {
assertEquals("msg-B1", loadedB[0].id) assertEquals("msg-B1", loadedB[0].id)
assertEquals("msg-B2", loadedB[1].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.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -193,7 +192,7 @@ class ChatToolDedupeTest {
assertEquals(3, deduped.size) assertEquals(3, deduped.size)
// WS copies win; rest- copies of the same logical message are dropped // 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-1" })
assertTrue(deduped.none { it.id == "rest-s-2" }) assertTrue(deduped.none { it.id == "rest-s-2" })
// rest- user kept when no WS copy exists // rest- user kept when no WS copy exists
@@ -279,7 +278,7 @@ class ChatToolDedupeTest {
val merged = mergeTranscriptWithLive(listOf(rest), listOf(live)) val merged = mergeTranscriptWithLive(listOf(rest), listOf(live))
// Preserves cache/live-only user flags such as continuesActiveTurn. // Preserves cache/live-only user flags such as continuesActiveTurn.
assertSame(live, merged.single()) assertEquals(live.copy(restId = rest.id), merged.single())
} }
@Test @Test
@@ -551,7 +550,7 @@ class ChatToolDedupeTest {
val merged = mergeIncrementalTranscriptPage(incoming, current, "s", 1) val merged = mergeIncrementalTranscriptPage(incoming, current, "s", 1)
assertEquals(listOf("retry", "retry"), merged.map { it.content }) 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) ── // ── 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)) val deduped = dedupeCachedMessages(listOf(wsTool, restTool))
assertEquals(1, deduped.size) assertEquals(1, deduped.size)
assertEquals(wsTool, deduped.single()) assertEquals(wsTool.copy(restId = restTool.id), deduped.single())
} }
} }
@@ -335,10 +335,21 @@ class ChatViewModelTest {
// ── Helpers ────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────
/** Create a ViewModel with the fake repo injected directly. */ /** 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 // Both dispatchers injected — a real ioDispatcher would race the
// test scheduler (repo writes hop it) and shuffle RPC ordering. // 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, * Create ViewModel, simulate GatewayReady, feed SESSION_CREATE result,
@@ -349,8 +360,9 @@ class ChatViewModelTest {
*/ */
private suspend fun TestScope.createViewModelWithSession( private suspend fun TestScope.createViewModelWithSession(
startCleanup: Boolean = false, startCleanup: Boolean = false,
historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = testDispatcher,
): Pair<ChatViewModel, String> { ): Pair<ChatViewModel, String> {
val viewModel = createViewModel(startCleanup) val viewModel = createViewModel(startCleanup, historyDispatcher)
advanceUntilIdle() advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED 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) ───────────────────────────────────── // ── Attachment open (issue #724) ─────────────────────────────────────
@Test @Test
@@ -0,0 +1,39 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.fullbleed.fullBleedItemKeys
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns
import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex
import org.junit.Assert.assertEquals
import org.junit.Test
class FullBleedItemKeysTest {
@Test
fun keysIncludeReasoningToolsAndSystemButSkipEmptyProse() {
val turns =
groupIntoTurns(
listOf(
ChatMessage("u", MessageRole.USER, "question"),
ChatMessage("empty", MessageRole.ASSISTANT, ""),
ChatMessage("t", MessageRole.TOOL, "output"),
ChatMessage("a", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"),
ChatMessage("s", MessageRole.SYSTEM, "event"),
),
)
assertEquals(listOf("user-u", "reasoning-a", "tool-t", "prose-a", "sys-s"), fullBleedItemKeys(turns))
assertEquals(mapOf("u" to 0, "a" to 3), messageIdToLazyIndex(turns))
assertEquals(mapOf("u" to 2, "a" to 5), messageIdToLazyIndex(turns, leadingItems = 2))
}
@Test
fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() {
val tool = ChatMessage("live-tool", MessageRole.TOOL, "output")
val older =
(0..149).map {
ChatMessage("old-$it", MessageRole.ASSISTANT, "answer", reasoningText = "thinking")
}
assertEquals(listOf("tool-live-tool"), fullBleedItemKeys(groupIntoTurns(listOf(tool))))
val keys = fullBleedItemKeys(groupIntoTurns(older + tool))
assertEquals("reasoning-old-0", keys.first())
assertEquals(151, keys.indexOf("tool-live-tool"))
}
}
@@ -104,6 +104,172 @@ class ReadNotificationReviewRegressionTest {
assertEquals(listOf("old-completion", "new-completion"), mapped.map { it.completionId }) 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 @Test
fun viewingOldCachedTextMustNotClearNewReplyNotification() { fun viewingOldCachedTextMustNotClearNewReplyNotification() {
postTarget("Done") postTarget("Done")
@@ -383,7 +549,11 @@ class ReadNotificationReviewRegressionTest {
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("The result MEDIA:/opt/pic.png")), SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("The result MEDIA:/opt/pic.png")),
) )
val mapped = mapServerMessages("session", history, 0, true, live, isPagingOlder = false) 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 @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.ChatMessageDao
import com.m57.hermescontrol.data.local.ChatMessageEntity import com.m57.hermescontrol.data.local.ChatMessageEntity
import com.m57.hermescontrol.data.local.canonicalMessageOrder
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap import java.util.concurrent.ConcurrentMap
/** /**
* In-memory [ChatMessageDao] for use in tests. * 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 { class FakeChatMessageDao : ChatMessageDao {
private val messages: ConcurrentMap<String, ChatMessageEntity> = ConcurrentHashMap() 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 sessionExists(sessionId: String): Boolean = messages.values.any { it.sessionId == sessionId }
override suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity> = override suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity> {
messages.values fullSessionReads++
beforeRead()
return messages.values
.filter { it.sessionId == sessionId } .filter { it.sessionId == sessionId }
.sortedBy { it.timestamp } .sortedWith(ordering)
override suspend fun upsert(message: ChatMessageEntity) {
messages[message.id] = message
} }
override suspend fun upsertAll(messageList: List<ChatMessageEntity>) { val pageLimits = mutableListOf<Int>()
messageList.forEach { messages[it.id] = it }
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) { override suspend fun deleteMessagesForSession(sessionId: String) {
@@ -33,14 +84,25 @@ class FakeChatMessageDao : ChatMessageDao {
/** Direct access for test setup — bypasses the suspend modifier. */ /** Direct access for test setup — bypasses the suspend modifier. */
fun addMessageDirect(message: ChatMessageEntity) { 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. */ /** Reset all stored messages. */
fun clear() { fun clear() {
messages.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. */ /** Returns the number of stored messages. */
fun count(): Int = messages.size fun count(): Int = messages.size
} }