feat(chat): add paged history with stable transcript and scroll identity (#1211)
Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
83b2a8fa9b
commit
a78b20bc99
26 files changed
+3942
-884
No files matched your search
@@ -10,6 +10,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.m57.hermescontrol"
|
||||
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
|
||||
|
||||
lint {
|
||||
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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
+379
@@ -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,
|
||||
)
|
||||
}
|
||||
+252
@@ -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
|
||||
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.Upsert
|
||||
|
||||
@Dao
|
||||
interface ChatMessageDao {
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE session_id = :sessionId)")
|
||||
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>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(message: ChatMessageEntity)
|
||||
@Query(
|
||||
"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)
|
||||
suspend fun upsertAll(messages: List<ChatMessageEntity>)
|
||||
@Query(
|
||||
"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")
|
||||
suspend fun deleteMessagesForSession(sessionId: String)
|
||||
|
||||
@@ -10,13 +10,13 @@ import androidx.room3.PrimaryKey
|
||||
* UI model but is stored independently so it survives process death.
|
||||
*
|
||||
* Messages are scoped by [sessionId] so switching sessions loads the right
|
||||
* thread. The [timestamp] field preserves original ordering even if Room
|
||||
* reorders internally.
|
||||
* thread. [sortOrder] preserves numeric server order or local insertion order;
|
||||
* [timestamp] is display metadata only.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "chat_messages",
|
||||
indices = [
|
||||
Index(value = ["session_id", "timestamp"]),
|
||||
Index(value = ["session_id", "sort_group", "sort_order", "id"]),
|
||||
],
|
||||
)
|
||||
data class ChatMessageEntity(
|
||||
@@ -48,4 +48,21 @@ data class ChatMessageEntity(
|
||||
val tps: Double? = null,
|
||||
@ColumnInfo(name = "completion_id")
|
||||
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,
|
||||
tps = tps,
|
||||
completionId = completionId,
|
||||
restId = restId,
|
||||
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 },
|
||||
)
|
||||
|
||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
@@ -56,4 +58,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
completionId = completionId,
|
||||
restId = restId,
|
||||
)
|
||||
@@ -14,7 +14,7 @@ import java.io.File
|
||||
|
||||
@Database(
|
||||
entities = [ChatMessageEntity::class],
|
||||
version = 8,
|
||||
version = 9,
|
||||
exportSchema = true,
|
||||
)
|
||||
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 =
|
||||
withContext(Dispatchers.IO) {
|
||||
instance?.let { return@withContext it }
|
||||
@@ -126,6 +151,7 @@ abstract class HermesDatabase : RoomDatabase() {
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8,
|
||||
MIGRATION_8_9,
|
||||
).fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
.also { instance = it }
|
||||
|
||||
@@ -87,8 +87,16 @@ data class ChatMessage(
|
||||
val tps: Double? = null,
|
||||
/** Stable completion identity correlating with reply notifications for read tracking. */
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -36,6 +36,50 @@ open class ChatPersistenceRepository(
|
||||
suspend fun loadMessages(sessionId: String): List<ChatMessage> =
|
||||
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). */
|
||||
suspend fun clearMessagesForSession(sessionId: String) {
|
||||
daoProvider().deleteMessagesForSession(sessionId)
|
||||
|
||||
@@ -59,7 +59,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.util.ConnectorUrlValidator
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val SESSION_SYNC_INTERVAL_MS = 30_000L
|
||||
@@ -211,7 +208,6 @@ fun ChatScreen(
|
||||
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
|
||||
val scrollScope = rememberCoroutineScope()
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
|
||||
var showContextSheet by remember { mutableStateOf(false) }
|
||||
var pendingSavePath 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.
|
||||
// Before prepending, capture the anchor (first visible message id + offset)
|
||||
// so the same content stays under the reader's eye after the insert
|
||||
// (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
|
||||
}
|
||||
}
|
||||
// FullBleedChatList preserves the live lazy row key and pixel offset on prepend.
|
||||
// Never restore a message index captured before an asynchronous fetch: it is not
|
||||
// a lazy row index and the reader may have moved in the meantime.
|
||||
|
||||
// Continuous bottom-follow tracking from LazyListState (issue #682).
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -705,6 +672,8 @@ fun ChatScreen(
|
||||
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
||||
isLoading = state.isLoading,
|
||||
isLoadingOlder = state.isLoadingOlder,
|
||||
hasOlderMessages = state.hasOlderMessages,
|
||||
pagingSessionId = state.currentSessionId,
|
||||
listState = listState,
|
||||
scrollController = scrollController,
|
||||
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||
|
||||
@@ -10,10 +10,9 @@ import com.m57.hermescontrol.notification.ReplyNotificationTracker
|
||||
/**
|
||||
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
|
||||
*
|
||||
* Verbatim extraction of ChatViewModel.mapServerMessages (corral 2 of the
|
||||
* 2026-08 delegate refactor). Pure with respect to the VM: it reads the live
|
||||
* message list supplied by the caller and one paging-mode flag — no state
|
||||
* mutation, no I/O beyond URL construction.
|
||||
* Reads the supplied transcript snapshot and recovers notification identity through
|
||||
* ReplyNotificationTracker's synchronized lookup. Safe on the history dispatcher;
|
||||
* it never mutates ViewModel state or dismisses a notification.
|
||||
*/
|
||||
internal fun mapServerMessages(
|
||||
sessionId: String,
|
||||
@@ -24,15 +23,11 @@ internal fun mapServerMessages(
|
||||
isPagingOlder: Boolean = false,
|
||||
context: android.content.Context? = null,
|
||||
): List<ChatMessage> {
|
||||
val existingReasoningMap =
|
||||
liveMessages
|
||||
.filter { it.reasoningText.isNotBlank() }
|
||||
.associateBy { it.content }
|
||||
|
||||
val existingById = liveMessages.associateBy { it.canonicalRestId ?: it.id }
|
||||
val liveByExactId =
|
||||
liveMessages
|
||||
.filter { !it.completionId.isNullOrBlank() }
|
||||
.associateBy { it.id }
|
||||
.associateBy { it.canonicalRestId ?: it.id }
|
||||
|
||||
val unmappedLiveWsAssistants =
|
||||
if (isPagingOlder) {
|
||||
@@ -40,112 +35,91 @@ internal fun mapServerMessages(
|
||||
} else {
|
||||
liveMessages
|
||||
.filter {
|
||||
it.role == MessageRole.ASSISTANT && !it.id.startsWith("rest-") &&
|
||||
it.role == MessageRole.ASSISTANT && it.canonicalRestId == null &&
|
||||
!it.completionId.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
|
||||
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
|
||||
if (!isPagingOlder) {
|
||||
if (unmappedLiveWsAssistants.isNotEmpty()) {
|
||||
val remainingWs = unmappedLiveWsAssistants.toMutableList()
|
||||
for (i in messages.indices.reversed()) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
fun restIdAt(index: Int): String =
|
||||
if (latestPaging) {
|
||||
"rest-$sessionId-${requireNotNull(messages[index].id) { "Latest transcript row has no stable id" }}"
|
||||
} else {
|
||||
"rest-$sessionId-${offset + index}"
|
||||
}
|
||||
|
||||
// REST-only recovery is fail-closed: only a durable server row ID may
|
||||
// transfer notification identity after the WebSocket copy is gone.
|
||||
val activeTarget = ReplyNotificationTracker.getActiveTarget(context)
|
||||
if (
|
||||
activeTarget != null &&
|
||||
activeTarget.sessionId == sessionId &&
|
||||
activeTarget.serverMessageId != null &&
|
||||
wsCompletionIdByRestIndex.values.none { it == activeTarget.completionId }
|
||||
) {
|
||||
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
|
||||
if (!isPagingOlder) {
|
||||
// #129: reserve durable identity before matching any repeated live prose.
|
||||
// REST-only recovery remains fail-closed when the target row is absent.
|
||||
val activeTarget = ReplyNotificationTracker.getActiveTarget(context)?.takeIf { it.sessionId == sessionId }
|
||||
val durableTarget = activeTarget?.takeIf { it.serverMessageId != null }
|
||||
if (durableTarget != null) {
|
||||
val exactIndex =
|
||||
messages.indexOfFirst { message ->
|
||||
message.id == activeTarget.serverMessageId &&
|
||||
message.id == durableTarget.serverMessageId &&
|
||||
message.role.equals("assistant", ignoreCase = true)
|
||||
}
|
||||
if (exactIndex >= 0 && !liveByExactId.containsKey("rest-$sessionId-${activeTarget.serverMessageId}")) {
|
||||
wsCompletionIdByRestIndex[exactIndex] = activeTarget.completionId
|
||||
if (exactIndex >= 0 && !liveByExactId.containsKey(restIdAt(exactIndex))) {
|
||||
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
|
||||
// stream was the only source of `toolName`. Match each REST tool row
|
||||
// to its WS counterpart by RESULT CONTENT (not position — pagination
|
||||
// and mixed cache state make positional mapping misalign, leaving
|
||||
// the newest call with a null name → generic "tool" bubble). When a
|
||||
// match is found the live message is reused wholesale (same id +
|
||||
// toolName + rich payload), so persistence upserts the same row
|
||||
// instead of accumulating a second `rest-` copy in Room. Issue #771.
|
||||
val liveToolByResult = linkedMapOf<String, ChatMessage>()
|
||||
liveMessages
|
||||
.filter { it.role == MessageRole.TOOL }
|
||||
.sortedBy { it.id.startsWith("rest-") } // prefer live WS copies
|
||||
.forEach { msg ->
|
||||
canonicalToolResultKey(msg.content)?.let { key ->
|
||||
liveToolByResult.putIfAbsent(key, msg)
|
||||
// Reasoning follows the same reserved identities as completions, never a reusable text lookup.
|
||||
val assistants = liveMessages.filter { it.role == MessageRole.ASSISTANT }
|
||||
val assistantsByRestId = assistants.groupBy { it.canonicalRestId }
|
||||
val assistantsByCompletion = assistants.groupBy { it.completionId }
|
||||
val reasoningSources = arrayOfNulls<ChatMessage>(messages.size)
|
||||
messages.forEachIndexed { index, row ->
|
||||
if (row.role?.lowercase() in listOf("user", "system", "tool")) return@forEachIndexed
|
||||
val exact = assistantsByRestId[restIdAt(index)].orEmpty()
|
||||
val completion = liveByExactId[restIdAt(index)]?.completionId ?: wsCompletionIdByRestIndex[index]
|
||||
val candidates = exact + completion?.let { assistantsByCompletion[it] }.orEmpty()
|
||||
reasoningSources[index] = candidates.firstOrNull { it.reasoningText.isNotBlank() } ?: candidates.firstOrNull()
|
||||
}
|
||||
if (!isPagingOlder) {
|
||||
val remaining =
|
||||
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>()
|
||||
// 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 ->
|
||||
val role =
|
||||
when (msg.role?.lowercase()) {
|
||||
@@ -154,47 +128,32 @@ internal fun mapServerMessages(
|
||||
"tool" -> MessageRole.TOOL
|
||||
else -> MessageRole.ASSISTANT
|
||||
}
|
||||
val globalIndex = offset + index
|
||||
// Issue #859: under newest-anchored paging use the server's
|
||||
// AUTOINCREMENT row id as the stable key — from-end positions shift
|
||||
// as the transcript grows and would collide across hydrations
|
||||
// (distinctBy would silently drop the newest copy). Legacy paging
|
||||
// keeps the absolute-position key its count-based sync math needs.
|
||||
val restId =
|
||||
if (latestPaging) {
|
||||
msg.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-$globalIndex"
|
||||
} else {
|
||||
"rest-$sessionId-$globalIndex"
|
||||
}
|
||||
val restId = restIdAt(index)
|
||||
val timestamp =
|
||||
msg.timestampText
|
||||
?.toDoubleOrNull()
|
||||
?.times(1000)
|
||||
?.toLong()
|
||||
?: existingById[restId]?.timestamp
|
||||
?: System.currentTimeMillis()
|
||||
|
||||
val rawContent = msg.contentText
|
||||
val rowReasoning =
|
||||
if (msg.reasoningText.isNotBlank()) {
|
||||
msg.reasoningText
|
||||
} else {
|
||||
existingReasoningMap[rawContent]?.reasoningText.orEmpty()
|
||||
msg.reasoningText.ifBlank {
|
||||
if (role == MessageRole.ASSISTANT) {
|
||||
reasoningSources[index]?.reasoningText.orEmpty()
|
||||
} else {
|
||||
existingById[restId]?.reasoningText.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
// Empty assistant row — two cases stored by the gateway:
|
||||
// 1. Reasoning-only: thinking-model split storage (content = "",
|
||||
// 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
|
||||
}
|
||||
// Retain canonical reasoning rows across page boundaries; hide only textless placeholders.
|
||||
if (role == MessageRole.ASSISTANT && rawContent.isBlank() && rowReasoning.isBlank()) return@forEachIndexed
|
||||
|
||||
// Context-reference preprocessing can persist model-facing attachment
|
||||
// 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 =
|
||||
if (role == MessageRole.ASSISTANT) {
|
||||
liveByExactId[restId]?.completionId ?: wsCompletionIdByRestIndex[index]
|
||||
@@ -272,7 +203,7 @@ internal fun mapServerMessages(
|
||||
id = restId,
|
||||
role = role,
|
||||
content = finalContent,
|
||||
reasoningText = finalReasoning,
|
||||
reasoningText = rowReasoning,
|
||||
toolCallId = msg.toolCallId,
|
||||
attachments = attachments,
|
||||
timestamp = timestamp,
|
||||
@@ -284,17 +215,12 @@ internal fun mapServerMessages(
|
||||
)
|
||||
}
|
||||
|
||||
// A reasoning-only row with no following answer (interrupted turn):
|
||||
// don't drop the trace — attach it to the last assistant message.
|
||||
if (pendingReasoning != null) {
|
||||
val lastAssistantIdx = mapped.indexOfLast { it.role == MessageRole.ASSISTANT }
|
||||
if (lastAssistantIdx >= 0) {
|
||||
val target = mapped[lastAssistantIdx]
|
||||
if (target.reasoningText.isBlank()) {
|
||||
mapped[lastAssistantIdx] = target.copy(reasoningText = pendingReasoning)
|
||||
}
|
||||
}
|
||||
// REST echoes must not reserve a match before the richer WS copy of that tool.
|
||||
val liveTools = liveMessages.filter { it.role == MessageRole.TOOL && !it.id.startsWith("rest-") }
|
||||
val mappedTools = mapped.filter { it.role == MessageRole.TOOL }
|
||||
val matches = matchTranscriptMessages(mappedTools, liveTools)
|
||||
val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] }
|
||||
return mapped.map { message ->
|
||||
toolsById[message.id]?.copy(restId = message.canonicalRestId) ?: message
|
||||
}
|
||||
|
||||
return mapped
|
||||
}
|
||||
@@ -107,48 +107,120 @@ internal fun canonicalToolResultKey(content: String): String? {
|
||||
internal fun sameLogicalMessage(
|
||||
a: ChatMessage,
|
||||
b: ChatMessage,
|
||||
): Boolean {
|
||||
if (a.role != b.role) return false
|
||||
if (a.role == MessageRole.TOOL) {
|
||||
// Issue #842: prefer the gateway's tool call id when both sides carry
|
||||
// it — the REST transcript stores `tool_call_id` and the live WS
|
||||
// bubble keeps it from `tool.start`. Content canonicalization cannot
|
||||
// cover MCP/web tools: the REST side stores the payload as raw
|
||||
// `<untrusted_tool_result>` text (not JSON), so it has no key at all.
|
||||
if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) {
|
||||
return a.toolCallId == b.toolCallId
|
||||
): Boolean = TranscriptComparison().same(a, b)
|
||||
|
||||
/** One operation owns this cache; even invalid JSON is parsed at most once per content. */
|
||||
internal class TranscriptComparison(
|
||||
private val canonicalize: (String) -> String? = ::canonicalToolResultKey,
|
||||
) {
|
||||
private val toolKeys = HashMap<String, String?>()
|
||||
private val trimmed = HashMap<String, String>()
|
||||
private val captions = HashMap<String, String>()
|
||||
|
||||
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)
|
||||
val kb = canonicalToolResultKey(b.content)
|
||||
return ka != null && ka == kb
|
||||
if (a.role == MessageRole.ASSISTANT && a.content.isBlank() && b.content.isBlank() &&
|
||||
(a.reasoningText.isNotBlank() || b.reasoningText.isNotBlank())
|
||||
) {
|
||||
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
|
||||
// Attachment dedupe: when the user sends an image/file, the backend
|
||||
// persists an ENRICHED copy of the prompt — the real caption plus
|
||||
// `@image:`/`@file:` reference lines and a `[screenshot]` marker
|
||||
// (tui_gateway _build_image_ref_message / run_agent flattening). The
|
||||
// optimistic bubble carries the raw caption only, so the exact compare
|
||||
// above fails and the REST sync renders the same message twice. Compare
|
||||
// USER rows on the caption with injection lines stripped (both sides —
|
||||
// stripping a clean string is a no-op). USER-only so assistant text that
|
||||
// legitimately mentions these tokens is never collapsed.
|
||||
if (a.role == MessageRole.USER) {
|
||||
val ca = stripAttachmentRefLines(ta)
|
||||
val cb = stripAttachmentRefLines(tb)
|
||||
if (ca == cb) return true
|
||||
}
|
||||
|
||||
/** Consume each occurrence once, reserving exact IDs before considering content echoes. */
|
||||
internal fun matchTranscriptMessages(
|
||||
incoming: List<ChatMessage>,
|
||||
existing: List<ChatMessage>,
|
||||
comparison: TranscriptComparison = TranscriptComparison(),
|
||||
allowAssistantContentMatches: Boolean = true,
|
||||
): List<ChatMessage?> {
|
||||
val byId = existing.withIndex().associate { it.value.id to it.index }
|
||||
val byRestId =
|
||||
existing.withIndex().mapNotNull { (index, message) -> message.canonicalRestId?.let { it to index } }.toMap()
|
||||
val used = BooleanArray(existing.size)
|
||||
val matches = arrayOfNulls<ChatMessage>(incoming.size)
|
||||
incoming.forEachIndexed { index, message ->
|
||||
(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
|
||||
// short of the streamed narration (the last delta was still in the
|
||||
// throttled buffer when tool.start sealed the message). The backend
|
||||
// persists the COMPLETE copy, so the orphan is a strict prefix of the
|
||||
// REST row. Accept prefix-covering only for substantial texts (>=40
|
||||
// chars) so a short reply can never be swallowed by a longer message
|
||||
// that merely starts with it.
|
||||
return ta.length >= 40 &&
|
||||
tb.length >= 40 &&
|
||||
(tb.startsWith(ta) || ta.startsWith(tb))
|
||||
val byRole = existing.indices.groupBy { existing[it].role }
|
||||
val byCall =
|
||||
existing.indices
|
||||
.filter { existing[it].toolCallId.isNotBlank() }
|
||||
.groupBy { existing[it].toolCallId }
|
||||
val byResult =
|
||||
existing.indices
|
||||
.filter { existing[it].role == MessageRole.TOOL }
|
||||
.groupBy { comparison.toolKey(existing[it].content) }
|
||||
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
|
||||
* logical message exists (issue #771).
|
||||
*/
|
||||
internal fun dedupeCachedMessages(messages: List<ChatMessage>): List<ChatMessage> {
|
||||
val rest = messages.filter { it.id.startsWith("rest-") }
|
||||
if (rest.isEmpty()) return messages
|
||||
val nonRest = messages.filterNot { it.id.startsWith("rest-") }
|
||||
if (nonRest.isEmpty()) return messages
|
||||
val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } }
|
||||
return (nonRest + keepRest).sortedBy { it.timestamp }
|
||||
internal fun dedupeCachedMessages(
|
||||
messages: List<ChatMessage>,
|
||||
confirmedOnly: Boolean = false,
|
||||
): List<ChatMessage> {
|
||||
val unique = messages.dedupeById()
|
||||
val rest = unique.filter { it.id.startsWith("rest-") }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A transcript reload must NOT yank live WS bubbles the server has not
|
||||
* persisted yet. The gateway stores a tool row only once the tool
|
||||
* COMPLETES server-side, so a reload that lands while a tool is running
|
||||
* (app background/foreground mid-turn, reconnect re-resume, pull-refresh)
|
||||
* returns a page without the tool row — replacing the list outright made
|
||||
* the in-flight tool bubble vanish and left tool.complete with no RUNNING
|
||||
* message to update (issue #771).
|
||||
*
|
||||
* Merge instead of replace: append any current message the REST page does
|
||||
* not already cover (checked by id AND logical content via
|
||||
* [sameLogicalMessage]) and keep chronological order.
|
||||
*/
|
||||
/** Cache pages enrich confirmed identities without changing keys already on screen (#859). */
|
||||
internal fun mergeCachedTranscriptPage(
|
||||
page: List<ChatMessage>,
|
||||
current: List<ChatMessage>,
|
||||
): List<ChatMessage> {
|
||||
val currentById = current.associateBy { it.id }
|
||||
// Legacy cached UUIDs may lack an alias. Restore a known alias before page-local matching
|
||||
// so an older identical REST row cannot claim that UUID again.
|
||||
val incoming =
|
||||
dedupeCachedMessages(
|
||||
page.map { message ->
|
||||
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(
|
||||
restMessages: List<ChatMessage>,
|
||||
currentMessages: List<ChatMessage>,
|
||||
chronological: Boolean = true,
|
||||
preserveLiveIds: Boolean = false,
|
||||
): List<ChatMessage> {
|
||||
val dedupedRest = restMessages.dedupeById()
|
||||
val dedupedCurrent = currentMessages.dedupeById()
|
||||
// User rows can carry live/cache-only metadata (for example whether the
|
||||
// bubble continues the active turn). Keep that richer copy when REST
|
||||
// returns the same logical row.
|
||||
val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList()
|
||||
val mergedRest =
|
||||
dedupedRest.map { rest ->
|
||||
if (rest.role != MessageRole.USER) return@map rest
|
||||
val matchIndex =
|
||||
currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 }
|
||||
?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) }
|
||||
if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest
|
||||
val incoming = restMessages.dedupeById()
|
||||
val current = currentMessages.dedupeById()
|
||||
val matches = matchTranscriptMessages(incoming, current, allowAssistantContentMatches = chronological)
|
||||
val consumed = matches.mapNotNull { it?.id }.toSet()
|
||||
val merged =
|
||||
incoming.mapIndexed { index, message ->
|
||||
val match = matches[index]
|
||||
// Keep local user metadata and stable IDs already used by the renderer.
|
||||
when {
|
||||
match?.role == MessageRole.USER -> {
|
||||
match.copy(
|
||||
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
||||
)
|
||||
}
|
||||
|
||||
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 liveTail =
|
||||
dedupedCurrent.filter { old ->
|
||||
old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) }
|
||||
val transcript =
|
||||
if (chronological) {
|
||||
current.filterNot { it.id in consumed } + merged
|
||||
} else {
|
||||
merged + current.filterNot { it.id in consumed }
|
||||
}
|
||||
if (liveTail.isEmpty()) return mergedRest.dedupeById()
|
||||
return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp }
|
||||
// #129: a mapped completion can confirm a cached REST row while its rich UUID
|
||||
// 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. */
|
||||
internal fun mergeIncrementalTranscriptPage(
|
||||
restMessages: List<ChatMessage>,
|
||||
@@ -234,7 +405,7 @@ internal fun mergeIncrementalTranscriptPage(
|
||||
): List<ChatMessage> {
|
||||
val settledEnd =
|
||||
currentMessages.indexOfLast { message ->
|
||||
serverMessageIndex(message.id, sessionId)?.let { it < offset } == true
|
||||
serverMessageIndex(message.canonicalRestId ?: message.id, sessionId)?.let { it < offset } == true
|
||||
}
|
||||
return (
|
||||
currentMessages.take(settledEnd + 1) +
|
||||
|
||||
@@ -45,6 +45,8 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -374,6 +376,7 @@ class ChatViewModel(
|
||||
slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext),
|
||||
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
|
||||
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||
private val historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = searchDispatcher,
|
||||
) : AndroidViewModel(application) {
|
||||
constructor(application: Application) : this(application, startCleanup = true)
|
||||
|
||||
@@ -443,6 +446,15 @@ class ChatViewModel(
|
||||
*/
|
||||
private var latestPaging = 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) ────────────────────────
|
||||
// Bounded auto-retry with exponential backoff, mirroring the desktop's
|
||||
@@ -642,7 +654,19 @@ class ChatViewModel(
|
||||
activeHydrationRequestSequence = ++hydrationRequestSequence
|
||||
// Preserve metadata so late results and errors are rejected as stale.
|
||||
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
|
||||
// across the disconnect (delegated to HermesWsClient, issue #526).
|
||||
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(
|
||||
sessionId: String,
|
||||
generation: Long,
|
||||
): Job =
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val cachedMessages = dedupeCachedMessages(repo.loadMessages(sessionId))
|
||||
_uiState.update { state ->
|
||||
// Only paint if still showing this session AND no fresher server
|
||||
// page has landed yet (a fast REST fetch must not be clobbered
|
||||
// by a stale cache read that loses the race).
|
||||
if (isCurrentSessionRequest(sessionId, generation) &&
|
||||
state.messages.isEmpty() &&
|
||||
cachedMessages.isNotEmpty()
|
||||
) {
|
||||
state.copy(
|
||||
messages = cachedMessages,
|
||||
isLoading = false,
|
||||
todos = hydrateTodosFromMessages(cachedMessages),
|
||||
)
|
||||
} else {
|
||||
state
|
||||
): Job {
|
||||
cacheJob?.cancel()
|
||||
return viewModelScope
|
||||
.launch {
|
||||
try {
|
||||
readCachedPage(sessionId, generation)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (isCurrentSessionRequest(sessionId, generation)) {
|
||||
// The first read can be retried by the same older-history action.
|
||||
cacheHasOlder = true
|
||||
publishHistoryAvailability()
|
||||
_uiState.update { it.copy(errorMessage = "Failed to load cached messages: ${e.message}") }
|
||||
}
|
||||
} finally {
|
||||
if (isCurrentSessionRequest(sessionId, generation) && _uiState.value.messages.isNotEmpty()) {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.also { cacheJob = it }
|
||||
}
|
||||
|
||||
private fun loadSessionMessages(
|
||||
sessionId: String,
|
||||
generation: Long,
|
||||
) {
|
||||
if (activeHydrationRequestSequence != 0L && cacheJob?.isActive == true) {
|
||||
cacheJob?.cancel()
|
||||
if (!cacheLoaded) loadCachedMessages(sessionId, generation)
|
||||
}
|
||||
val requestSequence = ++hydrationRequestSequence
|
||||
activeHydrationRequestSequence = requestSequence
|
||||
viewModelScope.launch {
|
||||
// Initial page: ask for the NEWEST page directly (order=latest —
|
||||
// offset is measured back from the newest message, page returned
|
||||
// chronologically; verified in hermes_state.py get_messages).
|
||||
// No count-based anchor, so a stale session-list message_count can
|
||||
// no longer land the page at the wrong position (issue #859).
|
||||
// Legacy backends without the `order` param ignore it and echo no
|
||||
// `pagination` — detect that and fall back to the count-based
|
||||
// anchor so nothing regresses.
|
||||
val latestResult = fetchMessagePage(sessionId, 0, MESSAGE_PAGE_SIZE, order = "latest")
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
||||
val (result, requestedOffset) =
|
||||
if (latestResult is NetworkResult.Success && latestResult.data.pagination?.order == "latest") {
|
||||
latestPaging = true
|
||||
latestResult to 0
|
||||
} else if (latestResult is NetworkResult.Success) {
|
||||
latestPaging = false
|
||||
val messageCount = fetchServerMessageCount(sessionId, generation, requestSequence)
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
||||
val offset = (messageCount - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
|
||||
fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE) to offset
|
||||
} else {
|
||||
latestPaging = false
|
||||
latestResult to 0
|
||||
}
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
// REST 200 — the gateway has the row for this session.
|
||||
sessionHasServerPresence = true
|
||||
val serverOffset = result.data.pagination?.offset ?: result.data.offset ?: requestedOffset
|
||||
val chatMessages =
|
||||
mapServerMessages(
|
||||
sessionId,
|
||||
result.data.messages.orEmpty(),
|
||||
serverOffset,
|
||||
latestPaging,
|
||||
_uiState.value.messages,
|
||||
context = getApplication(),
|
||||
)
|
||||
loadedMessageOffset = serverOffset
|
||||
withContext(ioDispatcher) {
|
||||
repo.persistMessages(chatMessages, sessionId)
|
||||
}
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
||||
_uiState.update { state ->
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update state
|
||||
// Merge, don't replace: a reload mid-turn must not
|
||||
// drop live WS bubbles (running tool call, streaming
|
||||
// answer) the server hasn't persisted yet. Issue #771.
|
||||
val merged = mergeTranscriptWithLive(chatMessages, state.messages)
|
||||
val hasOlder =
|
||||
if (latestPaging) {
|
||||
// Newest-anchored: older messages exist iff the
|
||||
// page came back FULL (a short page means we hit
|
||||
// the oldest boundary).
|
||||
val returned = result.data.pagination?.returned ?: chatMessages.size
|
||||
returned >= MESSAGE_PAGE_SIZE && chatMessages.isNotEmpty()
|
||||
hydrationJob?.cancel()
|
||||
olderJob?.cancel()
|
||||
syncJob?.cancel()
|
||||
isSyncingMessages = false
|
||||
_uiState.update { it.copy(isLoadingOlder = false) }
|
||||
val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
|
||||
hydrationJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val latestResult = fetchMessagePage(sessionId, 0, MESSAGE_PAGE_SIZE, order = "latest")
|
||||
if (!valid()) return@launch
|
||||
// Backends without stable latest IDs use the legacy absolute-position protocol.
|
||||
val useLatest =
|
||||
latestResult is NetworkResult.Success &&
|
||||
latestResult.data.pagination?.order == "latest" &&
|
||||
latestResult.data.messages.all { it.id != null }
|
||||
val (result, requestedOffset) =
|
||||
if (useLatest || latestResult is NetworkResult.Failure) {
|
||||
latestResult to 0
|
||||
} else {
|
||||
val count = fetchServerMessageCount(sessionId, generation, requestSequence)
|
||||
if (!valid()) return@launch
|
||||
val offset = (count - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
|
||||
fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE) to offset
|
||||
}
|
||||
if (!valid()) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val serverOffset = result.data.pagination?.offset ?: result.data.offset ?: requestedOffset
|
||||
val raw = result.data.messages
|
||||
val page =
|
||||
mergeHistoryPage(valid) { current ->
|
||||
mapServerMessages(
|
||||
sessionId,
|
||||
raw,
|
||||
serverOffset,
|
||||
useLatest,
|
||||
current,
|
||||
context = getApplication(),
|
||||
)
|
||||
} ?: return@launch
|
||||
persistHistoryPage(page, sessionId)
|
||||
if (!valid()) return@launch
|
||||
latestPaging = useLatest
|
||||
// Cursor counts RAW rows, including hidden reasoning/placeholder rows.
|
||||
loadedMessageOffset = if (useLatest) serverOffset + raw.size else serverOffset
|
||||
serverHasOlder = raw.isNotEmpty() &&
|
||||
if (useLatest) raw.size >= MESSAGE_PAGE_SIZE else serverOffset > 0
|
||||
sessionHasServerPresence = true
|
||||
publishHistoryAvailability()
|
||||
hydratedGeneration = generation
|
||||
finishResumeWhenHydrated(generation)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
val error = result.error.message
|
||||
if (error.contains("404", ignoreCase = true) ||
|
||||
error.contains("not found", ignoreCase = true)
|
||||
) {
|
||||
serverHasOlder = false
|
||||
publishHistoryAvailability()
|
||||
hydratedGeneration = generation
|
||||
finishResumeWhenHydrated(generation)
|
||||
} 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
|
||||
_uiState.update {
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isLoadingOlder = false,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (valid()) {
|
||||
hydratedGeneration = -1L
|
||||
handleResumeFailure(sessionId, generation, "Failed to load messages: ${e.message}")
|
||||
}
|
||||
// Route the transcript failure through the bounded resume
|
||||
// retry (desktop parity) instead of a one-shot snackbar —
|
||||
// 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}",
|
||||
)
|
||||
} finally {
|
||||
if (valid()) _uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
pendingInitialPrompt = null
|
||||
ActiveSessionHolder.clear()
|
||||
cacheJob?.cancel()
|
||||
hydrationJob?.cancel()
|
||||
olderJob?.cancel()
|
||||
syncJob?.cancel()
|
||||
activeHydrationRequestSequence = 0L
|
||||
cacheCursor = null
|
||||
cacheHasOlder = false
|
||||
cacheLoaded = false
|
||||
serverHasOlder = false
|
||||
loadedMessageOffset = 0
|
||||
latestPaging = false
|
||||
isSyncingMessages = false
|
||||
@@ -3372,222 +3455,150 @@ class ChatViewModel(
|
||||
fun loadOlderMessages() {
|
||||
val state = _uiState.value
|
||||
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
|
||||
if (!state.hasOlderMessages || state.isLoadingOlder) return
|
||||
if (!latestPaging && loadedMessageOffset <= 0) return
|
||||
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
|
||||
val requestSequence = activeHydrationRequestSequence
|
||||
val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
|
||||
_uiState.update { it.copy(isLoadingOlder = true) }
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
fetchMessagePage(
|
||||
sessionId,
|
||||
newOffset,
|
||||
limit,
|
||||
order = if (latestPaging) "latest" else null,
|
||||
)
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
||||
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
|
||||
olderJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
while (cacheHasOlder || !cacheLoaded) {
|
||||
val beforeMessages = _uiState.value.messages
|
||||
if (!readCachedPage(sessionId, generation, valid)) return@launch
|
||||
val afterMessages = _uiState.value.messages
|
||||
val addedRows =
|
||||
withContext(historyDispatcher) {
|
||||
val previousIds = beforeMessages.mapTo(HashSet()) { it.id }
|
||||
afterMessages.any { it.id !in previousIds }
|
||||
}
|
||||
current.copy(
|
||||
messages = (older + current.messages).distinctBy { it.id },
|
||||
isLoadingOlder = false,
|
||||
hasOlderMessages = hasOlder,
|
||||
)
|
||||
if (!valid()) return@launch
|
||||
if (addedRows) return@launch
|
||||
// Hydration persists canonical rows behind the local cache cursor. Consume
|
||||
// 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 -> {
|
||||
_uiState.update {
|
||||
if (isCurrentSessionRequest(sessionId, generation)) {
|
||||
it.copy(isLoadingOlder = false)
|
||||
} else {
|
||||
it
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = "Failed to load older messages: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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() {
|
||||
// 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
|
||||
val state = _uiState.value
|
||||
val sessionId = state.currentSessionId ?: return
|
||||
val generation = sessionGeneration
|
||||
if (isSyncingMessages || state.isLoading || state.isLoadingOlder || state.isAgentTyping ||
|
||||
_streamingState.value.streamingMessage != null
|
||||
if (isSyncingMessages || hydrationJob?.isActive == true || state.isLoading || state.isLoadingOlder ||
|
||||
state.isAgentTyping || _streamingState.value.streamingMessage != null
|
||||
) {
|
||||
return
|
||||
}
|
||||
val generation = sessionGeneration
|
||||
val requestSequence = activeHydrationRequestSequence
|
||||
val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
|
||||
val useLatest = latestPaging
|
||||
val nextOffset =
|
||||
if (latestPaging) {
|
||||
// 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).
|
||||
if (useLatest) {
|
||||
0
|
||||
} else {
|
||||
state.messages
|
||||
.mapNotNull { serverMessageIndex(it.id, sessionId) }
|
||||
.mapNotNull { serverMessageIndex(it.canonicalRestId ?: it.id, sessionId) }
|
||||
.maxOrNull()
|
||||
?.plus(1)
|
||||
?: loadedMessageOffset
|
||||
}
|
||||
isSyncingMessages = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result =
|
||||
fetchMessagePage(
|
||||
sessionId,
|
||||
nextOffset,
|
||||
MESSAGE_PAGE_SIZE,
|
||||
order = if (latestPaging) "latest" else null,
|
||||
)
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
||||
val incoming =
|
||||
mapServerMessages(
|
||||
sessionId,
|
||||
result.data.messages.orEmpty(),
|
||||
nextOffset,
|
||||
latestPaging,
|
||||
_uiState.value.messages,
|
||||
isPagingOlder = true,
|
||||
)
|
||||
if (incoming.isEmpty()) return@launch
|
||||
withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) }
|
||||
_uiState.update { current ->
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return@update current
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
syncJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result =
|
||||
fetchMessagePage(sessionId, nextOffset, MESSAGE_PAGE_SIZE, if (useLatest) "latest" else null)
|
||||
if (!valid()) return@launch
|
||||
if (result is NetworkResult.Success) {
|
||||
val offset = result.data.pagination?.offset ?: result.data.offset ?: nextOffset
|
||||
val page =
|
||||
mergeHistoryPage(valid) { current ->
|
||||
mapServerMessages(
|
||||
sessionId,
|
||||
result.data.messages,
|
||||
offset,
|
||||
useLatest,
|
||||
current,
|
||||
// Sync fetches recent replies, so confirm live completion identities.
|
||||
isPagingOlder = false,
|
||||
context = getApplication(),
|
||||
)
|
||||
} ?: return@launch
|
||||
persistHistoryPage(page, sessionId)
|
||||
// Never derive the older cursor from displayed rows or reset it to this latest page.
|
||||
// Append-only growth shifts from-end offsets toward newer rows: the next older
|
||||
// request may overlap, but stable IDs remove echoes without skipping any history.
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {}
|
||||
} catch (e: CancellationException) {
|
||||
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() {
|
||||
@@ -3926,15 +3937,17 @@ class ChatViewModel(
|
||||
offset: Int,
|
||||
limit: Int,
|
||||
order: String? = null,
|
||||
) = withContext(ioDispatcher) {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessionMessages(
|
||||
sessionId = sessionId,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
includeCompacted = true,
|
||||
order = order,
|
||||
)
|
||||
) = historyFetchMutex.withLock {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessionMessages(
|
||||
sessionId = sessionId,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
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
|
||||
* number of tail updates; shows on the FAB. Tapping the FAB resumes following
|
||||
* and clears the count.
|
||||
* 6. **Paging anchor preservation** — capture the first visible item + offset
|
||||
* with [captureAnchor] before prepending older history, then
|
||||
* [restoreAnchor] after insertion so the same content stays under the eye.
|
||||
* 6. **Paging anchors** — the full-bleed renderer preserves the live lazy row
|
||||
* key and offset when older history is inserted; this controller owns no
|
||||
* fetch-time anchor or animated compensation.
|
||||
* 7. **Serialized scroll commands** — every scroll (send, FAB, session switch,
|
||||
* search navigation, auto-follow) is launched from [scope] so animations
|
||||
* 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. */
|
||||
fun resumeFollowing() = jumpToBottom(animated = true)
|
||||
|
||||
/** True when the FAB should be visible (not following bottom + content exists). */
|
||||
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]).
|
||||
*
|
||||
@@ -258,7 +245,7 @@ fun tailContentKey(
|
||||
clarifyRequest: Any?,
|
||||
): Any =
|
||||
listOf(
|
||||
messages.size,
|
||||
messages.lastOrNull(),
|
||||
streamingMessage?.hashCode() ?: 0,
|
||||
isThinking,
|
||||
subagentIndicators.size,
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -12,9 +11,17 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
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.res.stringResource
|
||||
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.VaultSaveLoginPromptUi
|
||||
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.ClarifyBubble
|
||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||
@@ -93,6 +101,9 @@ fun FullBleedChatList(
|
||||
savingAttachmentPath: String? = null,
|
||||
openingAttachmentPath: String? = null,
|
||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||
hasOlderMessages: Boolean = false,
|
||||
pagingSessionId: String? = null,
|
||||
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
|
||||
) {
|
||||
if (messages.isEmpty() && !isLoading && !isAgentTyping) {
|
||||
Box(
|
||||
@@ -105,19 +116,133 @@ fun FullBleedChatList(
|
||||
)
|
||||
}
|
||||
} 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 =
|
||||
remember(messages, streamingState.streamingMessage) {
|
||||
groupIntoTurnsWithStreaming(messages, streamingState.streamingMessage)
|
||||
remember(settledTurns, streamingState.streamingMessage) {
|
||||
appendStreamingTurn(settledTurns, settledIds, streamingState.streamingMessage)
|
||||
}
|
||||
val agentStatus = deriveAgentStatus(isAgentTyping, streamingState, messages)
|
||||
// messageId → LazyColumn item index. The lazy list has EXTRA items
|
||||
// vs the message list (reasoning hoists, tool rows), so search-match
|
||||
// scrolling must resolve the lazy index, not use the message index.
|
||||
val lazyIndexById =
|
||||
remember(turns, isLoadingOlder) {
|
||||
messageIdToLazyIndex(turns, leadingItems = if (isLoadingOlder) 1 else 0)
|
||||
val agentStatus = deriveAgentStatus(isAgentTyping, streamingState, renderedMessages)
|
||||
// A single ordered definition supplies both emitted tail rows and anchor keys.
|
||||
val tailItems =
|
||||
buildMap<String, @Composable () -> Unit> {
|
||||
agentStatus?.let { status ->
|
||||
put("agent_status") {
|
||||
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
|
||||
// (not in ChatLifecycleEffects) because only this composable knows
|
||||
@@ -128,6 +253,7 @@ fun FullBleedChatList(
|
||||
searchState.currentIndex,
|
||||
searchState.matchIndices,
|
||||
searchState.matchOffsets,
|
||||
renderedMessages.firstOrNull()?.id,
|
||||
) {
|
||||
if (searchState.isActive &&
|
||||
searchState.currentIndex >= 0 &&
|
||||
@@ -135,6 +261,9 @@ fun FullBleedChatList(
|
||||
) {
|
||||
val messageIndex = searchState.matchIndices[searchState.currentIndex]
|
||||
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 contentOffset = searchState.matchOffsets.getOrElse(searchState.currentIndex) { 0 }
|
||||
val contentLength = messages[messageIndex].content.length
|
||||
@@ -153,202 +282,163 @@ fun FullBleedChatList(
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalDensity provides chatDensity) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
if (isLoadingOlder) {
|
||||
item(key = "loading-older") {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
turns.forEach { turn ->
|
||||
when (turn) {
|
||||
is ChatTurn.User -> {
|
||||
// Eager captures: item lambda reads these at
|
||||
// composition time (lazy), so capture now.
|
||||
val userMessage = turn.message
|
||||
item(key = "user-${userMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
renderChatBubble(
|
||||
message = userMessage,
|
||||
searchQuery = if (searchState.isActive) searchState.query else "",
|
||||
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,
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().nestedScroll(prefetchConnection),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
turns.forEach { turn ->
|
||||
when (turn) {
|
||||
is ChatTurn.User -> {
|
||||
// Eager captures: item lambda reads these at
|
||||
// composition time (lazy), so capture now.
|
||||
val userMessage = turn.message
|
||||
item(key = "user-${userMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
renderChatBubble(
|
||||
message = userMessage,
|
||||
searchQuery = if (searchState.isActive) searchState.query else "",
|
||||
isCurrentMatch =
|
||||
searchState.currentMatchId != null &&
|
||||
searchState.currentMatchId == userMessage.id,
|
||||
onOpenAttachment = viewModel::openAttachment,
|
||||
onSaveAttachment = onSaveAttachment,
|
||||
savingAttachmentPath = savingAttachmentPath,
|
||||
openingAttachmentPath = openingAttachmentPath,
|
||||
onImageClick = onImageClick,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
showUserMessageTokens = showUserMessageTokens,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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()) {
|
||||
item(key = "prose-${proseMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
if (proseMessage.isStreaming && typingEffectEnabled) {
|
||||
StreamingFullBleedWithTypingEffect(
|
||||
streaming = proseMessage,
|
||||
typingDelayMs = typingEffectDelayMs,
|
||||
showReasoning = !hoistedReasoning,
|
||||
)
|
||||
|
||||
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 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 {
|
||||
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,
|
||||
FullBleedSystemEvent(
|
||||
message = sysMessage,
|
||||
onRespondApproval = viewModel::respondToApproval,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 ->
|
||||
item(key = "agent_status") {
|
||||
AgentStatusIndicator(status = status)
|
||||
tailItems.forEach { (key, content) ->
|
||||
item(key = key) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
// Clarify bubble — rendered at the very bottom
|
||||
if (clarifyRequest != null) {
|
||||
item(key = "clarify_bubble") {
|
||||
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() },
|
||||
)
|
||||
}
|
||||
// Loading history must never become the list's first visible anchor.
|
||||
if (isLoadingOlder) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(12.dp).size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,19 +128,22 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
|
||||
fun groupIntoTurnsWithStreaming(
|
||||
messages: List<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> {
|
||||
if (streamingMessage == null || messages.any { it.id == streamingMessage.id }) {
|
||||
return groupIntoTurns(messages)
|
||||
}
|
||||
val turns = groupIntoTurns(messages).toMutableList()
|
||||
if (streamingMessage == null || streamingMessage.id in settledIds) return settled
|
||||
val prose = AgentEntry.Prose(streamingMessage)
|
||||
val last = turns.lastOrNull()
|
||||
if (last is ChatTurn.Agent) {
|
||||
turns[turns.lastIndex] = ChatTurn.Agent(last.entries + prose)
|
||||
val last = settled.lastOrNull()
|
||||
return if (last is ChatTurn.Agent) {
|
||||
settled.dropLast(1) + ChatTurn.Agent(last.entries + prose)
|
||||
} 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:
|
||||
* user turn → 1 item; agent turn → optional reasoning item, then one item per
|
||||
* visible entry. [leadingItems] accounts for fixed items emitted before the
|
||||
* turns (e.g. the `loading-older` spinner when paging).
|
||||
* visible entry, including later reasoning-only rows at their own positions.
|
||||
* [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
|
||||
* (prose items for agent messages; user items for user messages).
|
||||
@@ -165,48 +169,54 @@ fun groupIntoTurnsWithStreaming(
|
||||
fun messageIdToLazyIndex(
|
||||
turns: List<ChatTurn>,
|
||||
leadingItems: Int = 0,
|
||||
): Map<String, Int> {
|
||||
val map = mutableMapOf<String, Int>()
|
||||
var itemIndex = leadingItems
|
||||
turns.forEach { turn ->
|
||||
when (turn) {
|
||||
is ChatTurn.User -> {
|
||||
map[turn.message.id] = itemIndex
|
||||
itemIndex++
|
||||
): Map<String, Int> =
|
||||
fullBleedItemKeys(turns)
|
||||
.mapIndexedNotNull { index, key ->
|
||||
if (key.startsWith("user-") || key.startsWith("prose-")) {
|
||||
key.substringAfter('-') to index + leadingItems
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
is ChatTurn.Agent -> {
|
||||
val hasReasoning =
|
||||
turn.entries
|
||||
.filterIsInstance<AgentEntry.Prose>()
|
||||
.any { it.message.reasoningText.isNotBlank() }
|
||||
if (hasReasoning) {
|
||||
// The reasoning-<id> hoist item occupies one slot.
|
||||
itemIndex++
|
||||
/** Lazy row identities, including hoisted reasoning and grouped tool/system entries. */
|
||||
internal fun fullBleedItemKeys(turns: List<ChatTurn>): List<String> =
|
||||
buildList {
|
||||
turns.forEach { turn ->
|
||||
when (turn) {
|
||||
is ChatTurn.User -> {
|
||||
add("user-${turn.message.id}")
|
||||
}
|
||||
turn.entries.forEach { entry ->
|
||||
when (entry) {
|
||||
is AgentEntry.Prose -> {
|
||||
if (entry.message.hasVisibleAgentContent()) {
|
||||
map[entry.message.id] = itemIndex
|
||||
itemIndex++
|
||||
|
||||
is ChatTurn.Agent -> {
|
||||
val hoisted =
|
||||
turn.entries
|
||||
.filterIsInstance<AgentEntry.Prose>()
|
||||
.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 -> {
|
||||
itemIndex++
|
||||
}
|
||||
is AgentEntry.ToolRow -> {
|
||||
add("tool-${entry.message.id}")
|
||||
}
|
||||
|
||||
is AgentEntry.SystemEvent -> {
|
||||
itemIndex++
|
||||
is AgentEntry.SystemEvent -> {
|
||||
add("sys-${entry.message.id}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* 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())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pagedReadWaitsForLazyProviderAndRetainsCompletionIdentity() =
|
||||
runTest {
|
||||
val ready = kotlinx.coroutines.CompletableDeferred<com.m57.hermescontrol.data.local.ChatMessageDao>()
|
||||
var calls = 0
|
||||
val lazyRepository =
|
||||
ChatPersistenceRepository {
|
||||
calls++
|
||||
ready.await()
|
||||
}
|
||||
assertEquals(0, calls)
|
||||
val pending = async { lazyRepository.loadPage("s", null, 150) }
|
||||
kotlinx.coroutines.yield()
|
||||
assertEquals(1, calls)
|
||||
org.junit.Assert.assertFalse(pending.isCompleted)
|
||||
repository.persistMessage(
|
||||
ChatMessage(id = "rest-s-1", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp"),
|
||||
"s",
|
||||
)
|
||||
ready.complete(dao)
|
||||
val row = pending.await().messages.single()
|
||||
assertEquals("rest-s-1", row.canonicalRestId)
|
||||
assertEquals("comp", row.completionId)
|
||||
assertEquals(0, dao.fullSessionReads)
|
||||
assertEquals(listOf(151), dao.pageLimits)
|
||||
val failure = IllegalStateException("database unavailable")
|
||||
val failing = ChatPersistenceRepository { throw failure }
|
||||
org.junit.Assert.assertSame(failure, runCatching { failing.loadPage("s", null, 150) }.exceptionOrNull())
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
dao = FakeChatMessageDao()
|
||||
@@ -146,4 +176,123 @@ class ChatPersistenceRepositoryTest {
|
||||
assertEquals("msg-B1", loadedB[0].id)
|
||||
assertEquals("msg-B2", loadedB[1].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadPage_usesSeparateInitialAndCursorQueries() =
|
||||
runTest {
|
||||
val tracedDao = io.mockk.spyk(dao)
|
||||
val pagedRepository = ChatPersistenceRepository(tracedDao)
|
||||
(1..3).forEach { index ->
|
||||
pagedRepository.persistMessage(
|
||||
ChatMessage(id = "row-$index", role = MessageRole.USER, content = "$index", timestamp = 10L),
|
||||
"session",
|
||||
)
|
||||
}
|
||||
|
||||
val first = pagedRepository.loadPage("session", before = null, limit = 2)
|
||||
val second = pagedRepository.loadPage("session", before = first.cursor, limit = 2)
|
||||
|
||||
assertEquals(listOf("row-2", "row-3"), first.messages.map { it.id })
|
||||
assertEquals(listOf("row-1"), second.messages.map { it.id })
|
||||
assertEquals(true, first.hasOlder)
|
||||
assertEquals(false, second.hasOlder)
|
||||
io.mockk.coVerify(exactly = 1) { tracedDao.getLatestMessagePage("session", 3) }
|
||||
io.mockk.coVerify(exactly = 1) { tracedDao.getMessagePage("session", 1, 2L, "row-2", 3) }
|
||||
io.mockk.coVerify(exactly = 0) { tracedDao.getMessagesForSession(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadPage_equalTimestampsAndLiveInsertKeepCursorDeterministicAndSessionsIsolated() =
|
||||
runTest {
|
||||
val ids = (1..350).map { "rest-session-$it" }
|
||||
repository.persistMessages(
|
||||
ids.map { ChatMessage(id = it, role = MessageRole.USER, content = it, timestamp = 10L) },
|
||||
"session",
|
||||
)
|
||||
val first = repository.loadPage("session", before = null, limit = 150)
|
||||
repository.persistMessage(
|
||||
ChatMessage(id = "new", role = MessageRole.USER, content = "new", timestamp = 20L),
|
||||
"session",
|
||||
)
|
||||
repository.persistMessage(
|
||||
ChatMessage(id = "other", role = MessageRole.USER, content = "other", timestamp = 10L),
|
||||
"other-session",
|
||||
)
|
||||
val second = repository.loadPage("session", before = first.cursor, limit = 150)
|
||||
val third = repository.loadPage("session", before = second.cursor, limit = 150)
|
||||
|
||||
assertEquals(ids.takeLast(150), first.messages.map { it.id })
|
||||
assertEquals(ids, (third.messages + second.messages + first.messages).map { it.id })
|
||||
assertEquals(false, third.hasOlder)
|
||||
assertEquals(listOf(151, 151, 151), dao.pageLimits)
|
||||
assertEquals(0, dao.fullSessionReads)
|
||||
assertEquals(ids.toSet() + "new", dao.idsForSession("session"))
|
||||
assertEquals(setOf("other"), dao.idsForSession("other-session"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadPage_nonMonotonicTimestampsFollowServerIdsInEitherArrivalOrder() =
|
||||
runTest {
|
||||
val rows =
|
||||
listOf(9, 10, 100, 200).mapIndexed { index, id ->
|
||||
ChatMessage(
|
||||
id = "rest-session-$id",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "answer-$id",
|
||||
timestamp = listOf(900L, 100L, 800L, 200L)[index],
|
||||
)
|
||||
}
|
||||
for (pages in listOf(listOf(rows.take(2), rows.drop(2)), listOf(rows.drop(2), rows.take(2)))) {
|
||||
repository.clearMessagesForSession("session")
|
||||
pages.forEach { repository.persistMessages(it, "session") }
|
||||
val latest = repository.loadPage("session", null, 2)
|
||||
val older = repository.loadPage("session", latest.cursor, 2)
|
||||
assertEquals(rows.drop(2).map { it.id }, latest.messages.map { it.id })
|
||||
assertEquals(rows.take(2).map { it.id }, older.messages.map { it.id })
|
||||
assertEquals(true, latest.hasOlder)
|
||||
assertEquals(false, older.hasOlder)
|
||||
}
|
||||
assertEquals(0, dao.fullSessionReads)
|
||||
assertEquals(listOf(3, 3, 3, 3), dao.pageLimits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadPage_uuidOnlyRowsKeepInsertionOrderAcrossUpdatesAndLiveAppend() =
|
||||
runTest {
|
||||
val rows =
|
||||
listOf("ffffffff", "00000000", "aaaaaaaa").mapIndexed { index, prefix ->
|
||||
ChatMessage(
|
||||
id = "$prefix-0000-4000-8000-000000000000",
|
||||
role = MessageRole.USER,
|
||||
content = "message-$index",
|
||||
timestamp = listOf(900L, 100L, 800L)[index],
|
||||
)
|
||||
}
|
||||
rows.forEach { repository.persistMessage(it, "local") }
|
||||
repository.persistMessage(rows.first().copy(content = "edited", timestamp = 999L), "local")
|
||||
val latest = repository.loadPage("local", null, 2)
|
||||
repository.persistMessage(
|
||||
ChatMessage(
|
||||
id = "bbbbbbbb-0000-4000-8000-000000000000",
|
||||
role = MessageRole.USER,
|
||||
content = "appended after cursor",
|
||||
timestamp = 1L,
|
||||
),
|
||||
"local",
|
||||
)
|
||||
val older = repository.loadPage("local", latest.cursor, 2)
|
||||
assertEquals(rows.drop(1).map { it.id }, latest.messages.map { it.id })
|
||||
assertEquals(listOf(rows.first().id), older.messages.map { it.id })
|
||||
assertEquals("edited", older.messages.single().content)
|
||||
assertEquals(false, older.hasOlder)
|
||||
assertEquals(
|
||||
"appended after cursor",
|
||||
repository
|
||||
.loadPage("local", null, 1)
|
||||
.messages
|
||||
.single()
|
||||
.content,
|
||||
)
|
||||
assertEquals(0, dao.fullSessionReads)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -193,7 +192,7 @@ class ChatToolDedupeTest {
|
||||
|
||||
assertEquals(3, deduped.size)
|
||||
// WS copies win; rest- copies of the same logical message are dropped
|
||||
assertTrue(deduped.contains(wsTool))
|
||||
assertTrue(deduped.contains(wsTool.copy(restId = restTool.id)))
|
||||
assertTrue(deduped.none { it.id == "rest-s-1" })
|
||||
assertTrue(deduped.none { it.id == "rest-s-2" })
|
||||
// rest- user kept when no WS copy exists
|
||||
@@ -279,7 +278,7 @@ class ChatToolDedupeTest {
|
||||
val merged = mergeTranscriptWithLive(listOf(rest), listOf(live))
|
||||
|
||||
// Preserves cache/live-only user flags such as continuesActiveTurn.
|
||||
assertSame(live, merged.single())
|
||||
assertEquals(live.copy(restId = rest.id), merged.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -551,7 +550,7 @@ class ChatToolDedupeTest {
|
||||
val merged = mergeIncrementalTranscriptPage(incoming, current, "s", 1)
|
||||
|
||||
assertEquals(listOf("retry", "retry"), merged.map { it.content })
|
||||
assertSame(live, merged.last())
|
||||
assertEquals(live.copy(restId = "rest-s-1"), merged.last())
|
||||
}
|
||||
|
||||
// ── Issue #842: MCP/web tool rows (raw `<untrusted_tool_result>` text) ──
|
||||
@@ -654,6 +653,6 @@ The following content was retrieved from an external source.
|
||||
)
|
||||
val deduped = dedupeCachedMessages(listOf(wsTool, restTool))
|
||||
assertEquals(1, deduped.size)
|
||||
assertEquals(wsTool, deduped.single())
|
||||
assertEquals(wsTool.copy(restId = restTool.id), deduped.single())
|
||||
}
|
||||
}
|
||||
@@ -335,10 +335,21 @@ class ChatViewModelTest {
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a ViewModel with the fake repo injected directly. */
|
||||
private fun createViewModel(startCleanup: Boolean = false): ChatViewModel =
|
||||
private fun createViewModel(
|
||||
startCleanup: Boolean = false,
|
||||
historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = testDispatcher,
|
||||
): ChatViewModel =
|
||||
// Both dispatchers injected — a real ioDispatcher would race the
|
||||
// test scheduler (repo writes hop it) and shuffle RPC ordering.
|
||||
ChatViewModel(app, startCleanup, fakeRepo, fakeSlashUsageStore, testDispatcher, testDispatcher)
|
||||
ChatViewModel(
|
||||
app,
|
||||
startCleanup,
|
||||
fakeRepo,
|
||||
fakeSlashUsageStore,
|
||||
testDispatcher,
|
||||
testDispatcher,
|
||||
historyDispatcher,
|
||||
)
|
||||
|
||||
/**
|
||||
* Create ViewModel, simulate GatewayReady, feed SESSION_CREATE result,
|
||||
@@ -349,8 +360,9 @@ class ChatViewModelTest {
|
||||
*/
|
||||
private suspend fun TestScope.createViewModelWithSession(
|
||||
startCleanup: Boolean = false,
|
||||
historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = testDispatcher,
|
||||
): Pair<ChatViewModel, String> {
|
||||
val viewModel = createViewModel(startCleanup)
|
||||
val viewModel = createViewModel(startCleanup, historyDispatcher)
|
||||
advanceUntilIdle()
|
||||
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||
@@ -5198,6 +5210,824 @@ class ChatViewModelTest {
|
||||
)
|
||||
}
|
||||
|
||||
// Chat paging: deterministic regressions, with no real network or wall-clock timing.
|
||||
private fun seedPagingCache(
|
||||
count: Int,
|
||||
sameTimestamp: Boolean = false,
|
||||
): List<String> =
|
||||
(1..count).map { index ->
|
||||
val id = "cached-${index.toString().padStart(5, '0')}"
|
||||
fakeRepo.dao.addMessageDirect(
|
||||
com.m57.hermescontrol.data.local.ChatMessageEntity(
|
||||
id = id,
|
||||
sessionId = "session-456",
|
||||
role = "user",
|
||||
content = "Cached message $index",
|
||||
timestamp = if (sameTimestamp) 1L else index.toLong(),
|
||||
),
|
||||
)
|
||||
id
|
||||
}
|
||||
|
||||
private fun pagingResponse(
|
||||
rows: IntRange,
|
||||
offset: Int = 0,
|
||||
): retrofit2.Response<com.m57.hermescontrol.data.model.SessionMessagesResponse> =
|
||||
retrofit2.Response.success(
|
||||
com.m57.hermescontrol.data.model.SessionMessagesResponse(
|
||||
messages =
|
||||
rows.map { index ->
|
||||
com.m57.hermescontrol.data.model.SessionMessage(
|
||||
id = index,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Server message $index"),
|
||||
timestamp = JsonPrimitive(index),
|
||||
)
|
||||
},
|
||||
pagination =
|
||||
com.m57.hermescontrol.data.model.PaginationInfo(
|
||||
limit = 150,
|
||||
offset = offset,
|
||||
order = "latest",
|
||||
returned = rows.count(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun paging_syncNewestRestEcho_preservesLiveIdAndCompletionInOneBubble() =
|
||||
runTest {
|
||||
val api = ApiClient.hermesApi
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} returns pagingResponse(IntRange.EMPTY)
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
|
||||
// A completed live reply has no confirmed REST identity until its echo arrives.
|
||||
val live =
|
||||
ChatMessage(
|
||||
id = "live-reply",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Server message 42",
|
||||
timestamp = 42_000L,
|
||||
completionId = "comp",
|
||||
restId = null,
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val state =
|
||||
ChatViewModel::class.java
|
||||
.getDeclaredField("_uiState")
|
||||
.apply { isAccessible = true }
|
||||
.get(viewModel) as MutableStateFlow<Any>
|
||||
state.value = viewModel.uiState.value.copy(messages = listOf(live))
|
||||
advanceUntilIdle()
|
||||
assertNull(
|
||||
viewModel.uiState.value.messages
|
||||
.single()
|
||||
.restId,
|
||||
)
|
||||
assertEquals(
|
||||
"comp",
|
||||
viewModel.uiState.value.messages
|
||||
.single()
|
||||
.completionId,
|
||||
)
|
||||
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", 150, 0, "latest", any())
|
||||
} returns pagingResponse(42..42)
|
||||
|
||||
viewModel.syncCurrentSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 2) {
|
||||
api.getSessionMessages("session-456", 150, 0, "latest", any())
|
||||
}
|
||||
val messages = viewModel.uiState.value.messages
|
||||
assertEquals("The newest REST echo must confirm the live reply without adding a bubble", 1, messages.size)
|
||||
val message = messages.single()
|
||||
assertEquals("Server message 42", message.content)
|
||||
assertEquals("live-reply", message.id)
|
||||
assertEquals("rest-session-456-42", message.restId)
|
||||
assertEquals("comp", message.completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_largeCache_initialReadIsBoundedAtDao() =
|
||||
runTest {
|
||||
val expectedIds = seedPagingCache(10_000)
|
||||
stubSession456Rests(success = false)
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
expectedIds.takeLast(150),
|
||||
viewModel.uiState.value.messages
|
||||
.map { it.id },
|
||||
)
|
||||
assertEquals("Loading all rows then taking a suffix is not paging", 0, fakeRepo.dao.fullSessionReads)
|
||||
assertEquals(expectedIds.toSet(), fakeRepo.dao.idsForSession("session-456"))
|
||||
assertTrue(viewModel.uiState.value.hasOlderMessages)
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_offlinePagesRetainEveryRowWithEqualTimestamps() =
|
||||
runTest {
|
||||
val expectedIds = seedPagingCache(350, sameTimestamp = true)
|
||||
stubSession456Rests(success = false)
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
assertEquals(
|
||||
expectedIds.takeLast(150),
|
||||
viewModel.uiState.value.messages
|
||||
.map { it.id },
|
||||
)
|
||||
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
assertEquals(
|
||||
expectedIds.takeLast(300),
|
||||
viewModel.uiState.value.messages
|
||||
.map { it.id },
|
||||
)
|
||||
assertTrue(viewModel.uiState.value.hasOlderMessages)
|
||||
assertFalse(viewModel.uiState.value.isLoadingOlder)
|
||||
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
assertEquals(
|
||||
expectedIds,
|
||||
viewModel.uiState.value.messages
|
||||
.map { it.id },
|
||||
)
|
||||
assertFalse(viewModel.uiState.value.hasOlderMessages)
|
||||
assertFalse(viewModel.uiState.value.isLoadingOlder)
|
||||
assertEquals(expectedIds.toSet(), fakeRepo.dao.idsForSession("session-456"))
|
||||
assertEquals(0, fakeRepo.dao.fullSessionReads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_cacheEchoEnrichesExistingUuidBeforeEarlierRepeatedOccurrencesArrive() =
|
||||
runTest {
|
||||
val roles = listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)
|
||||
for (index in 1..303) {
|
||||
val occurrence =
|
||||
when (index) {
|
||||
in 1..3 -> index - 1
|
||||
in 151..153 -> index - 151
|
||||
in 301..303 -> index - 301
|
||||
else -> null
|
||||
}
|
||||
val role = occurrence?.let { roles[it] } ?: MessageRole.SYSTEM
|
||||
val id =
|
||||
when (index) {
|
||||
in 1..3 -> "rest-session-456-${10 + requireNotNull(occurrence)}"
|
||||
in 151..153 -> "rest-session-456-${20 + requireNotNull(occurrence)}"
|
||||
in 301..303 -> "uuid-${requireNotNull(occurrence)}"
|
||||
else -> "filler-$index"
|
||||
}
|
||||
fakeRepo.dao.addMessageDirect(
|
||||
com.m57.hermescontrol.data.local.ChatMessageEntity(
|
||||
id = id,
|
||||
sessionId = "session-456",
|
||||
role = role.name,
|
||||
content =
|
||||
when (role) {
|
||||
MessageRole.TOOL -> """{"output":"ok"}"""
|
||||
MessageRole.SYSTEM -> "filler $index"
|
||||
else -> "continue"
|
||||
},
|
||||
timestamp = index.toLong(),
|
||||
toolName = if (role == MessageRole.TOOL && index > 300) "terminal" else null,
|
||||
),
|
||||
)
|
||||
}
|
||||
val storedIds = fakeRepo.dao.idsForSession("session-456")
|
||||
stubSession456Rests(success = false)
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
assertEquals(150, viewModel.uiState.value.messages.size)
|
||||
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
for (index in roles.indices) {
|
||||
val retained =
|
||||
viewModel.uiState.value.messages
|
||||
.single { it.id == "uuid-$index" }
|
||||
assertEquals("rest-session-456-${20 + index}", retained.restId)
|
||||
}
|
||||
assertEquals(297, viewModel.uiState.value.messages.size)
|
||||
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
val messages = viewModel.uiState.value.messages
|
||||
assertEquals(300, messages.size)
|
||||
for (index in roles.indices) {
|
||||
assertEquals(2, messages.count { it.role == roles[index] })
|
||||
assertTrue(messages.any { it.id == "rest-session-456-${10 + index}" })
|
||||
assertTrue(messages.any { it.id == "uuid-$index" })
|
||||
}
|
||||
assertEquals("terminal", messages.single { it.id == "uuid-2" }.toolName)
|
||||
assertEquals(storedIds, fakeRepo.dao.idsForSession("session-456"))
|
||||
assertFalse(viewModel.uiState.value.hasOlderMessages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_reusedToolPersistsCanonicalKeyWithoutReplacingLiveKey() =
|
||||
runTest {
|
||||
fakeRepo.dao.addMessageDirect(
|
||||
com.m57.hermescontrol.data.local.ChatMessageEntity(
|
||||
id = "uuid-tool",
|
||||
sessionId = "session-456",
|
||||
role = "TOOL",
|
||||
content = """{"output":"ok"}""",
|
||||
timestamp = 1L,
|
||||
toolName = "terminal",
|
||||
),
|
||||
)
|
||||
val api = ApiClient.hermesApi
|
||||
val response = pagingResponse(42..42).body()!!
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} returns
|
||||
retrofit2.Response.success(
|
||||
response.copy(
|
||||
messages =
|
||||
response.messages.map {
|
||||
it.copy(
|
||||
role = "tool",
|
||||
content = JsonPrimitive("""{"output":"ok"}"""),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
|
||||
val message =
|
||||
viewModel.uiState.value.messages
|
||||
.single()
|
||||
assertEquals("uuid-tool", message.id)
|
||||
assertEquals("rest-session-456-42", message.restId)
|
||||
assertEquals("terminal", message.toolName)
|
||||
assertEquals(setOf("uuid-tool", "rest-session-456-42"), fakeRepo.dao.idsForSession("session-456"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_emptyServerPageDoesNotHideOlderOfflineHistory() =
|
||||
runTest {
|
||||
val api = ApiClient.hermesApi
|
||||
val expectedIds = seedPagingCache(350)
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} returns pagingResponse(IntRange.EMPTY)
|
||||
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(150, viewModel.uiState.value.messages.size)
|
||||
assertTrue(viewModel.uiState.value.hasOlderMessages)
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
assertEquals(300, viewModel.uiState.value.messages.size)
|
||||
assertEquals(expectedIds.toSet(), fakeRepo.dao.idsForSession("session-456"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_cacheFirst_serverHydrationDoesNotInflateInitialWindow() =
|
||||
runTest {
|
||||
val api = ApiClient.hermesApi
|
||||
seedPagingCache(10_000)
|
||||
val response =
|
||||
CompletableDeferred<retrofit2.Response<com.m57.hermescontrol.data.model.SessionMessagesResponse>>()
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} coAnswers { response.await() }
|
||||
|
||||
viewModel.switchSession("session-456")
|
||||
runCurrent()
|
||||
val cacheSize = viewModel.uiState.value.messages.size
|
||||
response.complete(pagingResponse(1..150))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(150, cacheSize)
|
||||
assertEquals(300, viewModel.uiState.value.messages.size)
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
assertTrue(viewModel.uiState.value.hasOlderMessages)
|
||||
assertEquals(0, fakeRepo.dao.fullSessionReads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_serverFirst_cacheCursorStillAllowsOfflineHistory() =
|
||||
runTest {
|
||||
val api = ApiClient.hermesApi
|
||||
seedPagingCache(350)
|
||||
val cacheRead = CompletableDeferred<Unit>()
|
||||
fakeRepo.dao.beforeRead = { cacheRead.await() }
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} returns pagingResponse(IntRange.EMPTY)
|
||||
|
||||
viewModel.switchSession("session-456")
|
||||
runCurrent()
|
||||
cacheRead.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(150, viewModel.uiState.value.messages.size)
|
||||
assertTrue(viewModel.uiState.value.hasOlderMessages)
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
assertEquals(300, viewModel.uiState.value.messages.size)
|
||||
assertFalse(viewModel.uiState.value.isLoadingOlder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_pendingOlderPageIsSuppressedAndFailureAllowsRetry() =
|
||||
runTest {
|
||||
val api = ApiClient.hermesApi
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} returns pagingResponse(151..300)
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
val pending =
|
||||
CompletableDeferred<retrofit2.Response<com.m57.hermescontrol.data.model.SessionMessagesResponse>>()
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", 150, 150, "latest", any())
|
||||
} coAnswers { pending.await() }
|
||||
|
||||
viewModel.loadOlderMessages()
|
||||
runCurrent()
|
||||
repeat(5) { viewModel.loadOlderMessages() }
|
||||
runCurrent()
|
||||
coVerify(exactly = 1) {
|
||||
api.getSessionMessages("session-456", 150, 150, "latest", any())
|
||||
}
|
||||
assertTrue(viewModel.uiState.value.isLoadingOlder)
|
||||
pending.complete(retrofit2.Response.error(500, okhttp3.ResponseBody.create(null, "test failure")))
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isLoadingOlder)
|
||||
assertTrue(viewModel.uiState.value.hasOlderMessages)
|
||||
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", 150, 150, "latest", any())
|
||||
} returns pagingResponse(1..150, offset = 150)
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
assertEquals(300, viewModel.uiState.value.messages.size)
|
||||
assertFalse(viewModel.uiState.value.isLoadingOlder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_oldSessionResponseCannotReplaceNewSession() =
|
||||
runTest {
|
||||
val api = ApiClient.hermesApi
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} returns pagingResponse(151..300)
|
||||
coEvery {
|
||||
api.getSessionMessages("session-other", any(), any(), any(), any())
|
||||
} returns pagingResponse(900..900)
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
val pending =
|
||||
CompletableDeferred<retrofit2.Response<com.m57.hermescontrol.data.model.SessionMessagesResponse>>()
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", 150, 150, "latest", any())
|
||||
} coAnswers { pending.await() }
|
||||
|
||||
viewModel.loadOlderMessages()
|
||||
runCurrent()
|
||||
viewModel.switchSession("session-other")
|
||||
runCurrent()
|
||||
pending.complete(pagingResponse(1..150, offset = 150))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("session-other", viewModel.uiState.value.currentSessionId)
|
||||
assertEquals(
|
||||
listOf("rest-session-other-900"),
|
||||
viewModel.uiState.value.messages
|
||||
.map { it.id },
|
||||
)
|
||||
assertFalse(viewModel.uiState.value.isLoadingOlder)
|
||||
}
|
||||
|
||||
/** Holds CPU continuations without sleeping or starting a real thread. */
|
||||
private class QueuedHistoryDispatcher : kotlinx.coroutines.CoroutineDispatcher() {
|
||||
private val queue = ArrayDeque<Runnable>()
|
||||
val pending: Boolean get() = queue.isNotEmpty()
|
||||
|
||||
override fun dispatch(
|
||||
context: kotlin.coroutines.CoroutineContext,
|
||||
block: Runnable,
|
||||
) {
|
||||
queue.addLast(block)
|
||||
}
|
||||
|
||||
fun runNext() = queue.removeFirst().run()
|
||||
}
|
||||
|
||||
private fun TestScope.drainHistory(dispatcher: QueuedHistoryDispatcher) {
|
||||
repeat(100) {
|
||||
runCurrent()
|
||||
if (!dispatcher.pending) return
|
||||
dispatcher.runNext()
|
||||
}
|
||||
error("History did not settle after 100 CPU continuations")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_liveMessageArrivingBeforeComputedSnapshotCommitSurvives() =
|
||||
runTest {
|
||||
val api = ApiClient.hermesApi
|
||||
val cpu = QueuedHistoryDispatcher()
|
||||
val (viewModel, _) = createViewModelWithSession(historyDispatcher = cpu)
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} returns pagingResponse(1..150)
|
||||
viewModel.switchSession("session-456")
|
||||
runCurrent()
|
||||
// Cache query runs first; hydration has already captured an empty snapshot.
|
||||
cpu.runNext()
|
||||
runCurrent()
|
||||
mockEventsFlow.emit(WsEvent.MessageComplete("Live during merge", "session-456"))
|
||||
cpu.runNext()
|
||||
// The queued WS event runs before the computed result can commit on Main.
|
||||
runCurrent()
|
||||
drainHistory(cpu)
|
||||
|
||||
val messages = viewModel.uiState.value.messages
|
||||
assertEquals(151, messages.size)
|
||||
assertEquals(1, messages.count { it.content == "Live during merge" })
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_sessionSwitchDiscardsQueuedCpuResult() =
|
||||
runTest {
|
||||
val api = ApiClient.hermesApi
|
||||
val cpu = QueuedHistoryDispatcher()
|
||||
val (viewModel, _) = createViewModelWithSession(historyDispatcher = cpu)
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} returns pagingResponse(1..150)
|
||||
coEvery {
|
||||
api.getSessionMessages("session-other", any(), any(), any(), any())
|
||||
} returns pagingResponse(900..900)
|
||||
viewModel.switchSession("session-456")
|
||||
runCurrent()
|
||||
cpu.runNext()
|
||||
runCurrent()
|
||||
cpu.runNext()
|
||||
viewModel.switchSession("session-other")
|
||||
drainHistory(cpu)
|
||||
|
||||
assertEquals("session-other", viewModel.uiState.value.currentSessionId)
|
||||
assertEquals(
|
||||
listOf("rest-session-other-900"),
|
||||
viewModel.uiState.value.messages
|
||||
.map { it.id },
|
||||
)
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_reconnectInvalidatesPendingOlderRequestAndItsCursor() =
|
||||
runTest {
|
||||
val api = ApiClient.hermesApi
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} returns pagingResponse(301..450)
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
val oldResumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second
|
||||
mockEventsFlow.emit(WsEvent.RpcResult(oldResumeId, mapOf("session_id" to "runtime-456")))
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.uiState.value.isSessionReady)
|
||||
val pending =
|
||||
CompletableDeferred<retrofit2.Response<com.m57.hermescontrol.data.model.SessionMessagesResponse>>()
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", 150, 150, "latest", any())
|
||||
} coAnswers { pending.await() }
|
||||
viewModel.loadOlderMessages()
|
||||
runCurrent()
|
||||
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", 150, 0, "latest", any())
|
||||
} returns pagingResponse(501..650)
|
||||
mockConnectionStatus.value = ConnectionStatus.RECONNECTING
|
||||
runCurrent()
|
||||
assertFalse(viewModel.uiState.value.isSessionReady)
|
||||
assertFalse(viewModel.uiState.value.isLoadingOlder)
|
||||
assertFalse(viewModel.sendMessage("preserved draft"))
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||
runCurrent()
|
||||
pending.complete(pagingResponse(1..150, offset = 150))
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isLoadingOlder)
|
||||
assertFalse(
|
||||
viewModel.uiState.value.messages
|
||||
.any { it.id == "rest-session-456-1" },
|
||||
)
|
||||
|
||||
mockEventsFlow.emit(WsEvent.RpcResult(oldResumeId, mapOf("session_id" to "stale-runtime")))
|
||||
runCurrent()
|
||||
assertFalse(viewModel.uiState.value.isSessionReady)
|
||||
val freshResumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second
|
||||
mockEventsFlow.emit(WsEvent.RpcResult(freshResumeId, mapOf("session_id" to "fresh-runtime")))
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.uiState.value.isSessionReady)
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", 150, 150, "latest", any())
|
||||
} returns pagingResponse(351..500, offset = 150)
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
assertTrue(
|
||||
viewModel.uiState.value.messages
|
||||
.any { it.id == "rest-session-456-500" },
|
||||
)
|
||||
coVerify(exactly = 0) {
|
||||
api.getSessionMessages("session-456", 150, 300, "latest", any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_offlineCacheRemainsReadableAndFreshResumeRestoresSendReadiness() =
|
||||
runTest {
|
||||
val ids = seedPagingCache(350)
|
||||
stubSession456Rests(success = false)
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isSessionReady)
|
||||
assertFalse(viewModel.sendMessage("draft"))
|
||||
mockConnectionStatus.value = ConnectionStatus.NO_NETWORK
|
||||
runCurrent()
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
assertEquals(
|
||||
ids.takeLast(300),
|
||||
viewModel.uiState.value.messages
|
||||
.map { it.id },
|
||||
)
|
||||
assertFalse(viewModel.uiState.value.isLoadingOlder)
|
||||
val api = ApiClient.hermesApi
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} returns pagingResponse(IntRange.EMPTY)
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isSessionReady)
|
||||
val resumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second
|
||||
mockEventsFlow.emit(WsEvent.RpcResult(resumeId, mapOf("session_id" to "runtime-456")))
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.uiState.value.isSessionReady)
|
||||
assertTrue(viewModel.sendMessage("draft"))
|
||||
advanceUntilIdle()
|
||||
assertEquals(
|
||||
1,
|
||||
viewModel.uiState.value.messages
|
||||
.count { it.content == "draft" },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_cacheAndServerCursorsAdvanceIndependently() =
|
||||
runTest {
|
||||
val api = ApiClient.hermesApi
|
||||
val cachedIds = seedPagingCache(350)
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} returns pagingResponse(151..300)
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
repeat(2) {
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
}
|
||||
assertEquals(
|
||||
cachedIds.toSet() + (151..300).map { "rest-session-456-$it" },
|
||||
viewModel.uiState.value.messages
|
||||
.map { it.id }
|
||||
.toSet(),
|
||||
)
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", 150, 150, "latest", any())
|
||||
} returns pagingResponse(1..150, offset = 150)
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
|
||||
// The remaining cache page contains only the latest REST rows already on screen.
|
||||
// This same action must reach the independent server cursor, not stop on those echoes.
|
||||
coVerify(exactly = 1) {
|
||||
api.getSessionMessages("session-456", 150, 150, "latest", any())
|
||||
}
|
||||
val expectedIds = cachedIds.toSet() + (1..300).map { "rest-session-456-$it" }
|
||||
assertEquals(650, viewModel.uiState.value.messages.size)
|
||||
assertEquals(
|
||||
expectedIds,
|
||||
viewModel.uiState.value.messages
|
||||
.map { it.id }
|
||||
.toSet(),
|
||||
)
|
||||
assertEquals(expectedIds, fakeRepo.dao.idsForSession("session-456"))
|
||||
assertFalse(viewModel.uiState.value.isLoadingOlder)
|
||||
assertEquals(0, fakeRepo.dao.fullSessionReads)
|
||||
assertTrue(fakeRepo.dao.pageLimits.all { it == 151 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_latestGrowthBetweenPagesAndSyncNeverSkipsOlderRows() =
|
||||
runTest {
|
||||
val api = ApiClient.hermesApi
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
var total = 450
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} coAnswers {
|
||||
val offset = arg<Int>(2)
|
||||
val end = (total - offset).coerceAtLeast(0)
|
||||
val start = (end - 150).coerceAtLeast(0) + 1
|
||||
pagingResponse(start..end, offset)
|
||||
}
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
total = 460
|
||||
viewModel.syncCurrentSession()
|
||||
advanceUntilIdle()
|
||||
repeat(2) {
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
(1..460).map { "rest-session-456-$it" }.toSet(),
|
||||
viewModel.uiState.value.messages
|
||||
.map { it.id }
|
||||
.toSet(),
|
||||
)
|
||||
assertFalse(viewModel.uiState.value.hasOlderMessages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_rawHiddenRowsStillAdvanceServerCursor() =
|
||||
runTest {
|
||||
val api = ApiClient.hermesApi
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
val response = pagingResponse(1..150).body()!!
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} returns
|
||||
retrofit2.Response.success(
|
||||
response.copy(messages = response.messages.map { it.copy(content = JsonPrimitive("")) }),
|
||||
)
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.uiState.value.hasOlderMessages)
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", 150, 150, "latest", any())
|
||||
} returns pagingResponse(IntRange.EMPTY, offset = 150)
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.hasOlderMessages)
|
||||
coVerify(exactly = 1) {
|
||||
api.getSessionMessages("session-456", 150, 150, "latest", any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_cacheReadExceptionSettlesAndAllowsRetry() =
|
||||
runTest {
|
||||
val ids = seedPagingCache(350)
|
||||
stubSession456Rests(success = false)
|
||||
fakeRepo.dao.beforeRead = { error("Synthetic Room read failure") }
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
assertTrue(viewModel.uiState.value.hasOlderMessages)
|
||||
fakeRepo.dao.beforeRead = {}
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
ids.takeLast(150),
|
||||
viewModel.uiState.value.messages
|
||||
.map { it.id },
|
||||
)
|
||||
assertFalse(viewModel.uiState.value.isLoadingOlder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_mapperExceptionSettlesAndRetriesSameServerOffset() =
|
||||
runTest {
|
||||
val api = ApiClient.hermesApi
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", any(), any(), any(), any())
|
||||
} returns pagingResponse(151..300)
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
val invalid = pagingResponse(1..150, 150).body()!!
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", 150, 150, "latest", any())
|
||||
} returns retrofit2.Response.success(invalid.copy(messages = invalid.messages.map { it.copy(id = null) }))
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isLoadingOlder)
|
||||
assertTrue(viewModel.uiState.value.hasOlderMessages)
|
||||
coEvery {
|
||||
api.getSessionMessages("session-456", 150, 150, "latest", any())
|
||||
} returns pagingResponse(1..150, 150)
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
assertEquals(300, viewModel.uiState.value.messages.size)
|
||||
}
|
||||
|
||||
private suspend fun TestScope.assertOverlappingHistoryPageKeepsOrder(equalTimestamps: Boolean) {
|
||||
val api = ApiClient.hermesApi
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
(1..350).forEach { index ->
|
||||
fakeRepo.dao.addMessageDirect(
|
||||
com.m57.hermescontrol.data.local.ChatMessageEntity(
|
||||
id = "rest-session-456-$index",
|
||||
sessionId = "session-456",
|
||||
role = "ASSISTANT",
|
||||
content = "Server message $index",
|
||||
timestamp = if (equalTimestamps) 1_000L else index * 1_000L,
|
||||
),
|
||||
)
|
||||
}
|
||||
coEvery { api.getSessionMessages("session-456", any(), any(), any(), any()) } coAnswers {
|
||||
val offset = arg<Int>(2)
|
||||
val end = 350 - offset
|
||||
val response = pagingResponse((end - 149).coerceAtLeast(1)..end, offset).body()!!
|
||||
retrofit2.Response.success(
|
||||
if (equalTimestamps) {
|
||||
response.copy(messages = response.messages.map { it.copy(timestamp = JsonPrimitive(1)) })
|
||||
} else {
|
||||
response
|
||||
},
|
||||
)
|
||||
}
|
||||
viewModel.switchSession("session-456")
|
||||
advanceUntilIdle()
|
||||
repeat(2) {
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
}
|
||||
val before =
|
||||
viewModel.uiState.value.messages
|
||||
.map { it.id }
|
||||
assertEquals((1..350).map { "rest-session-456-$it" }.toSet(), before.toSet())
|
||||
assertEquals(350, before.size)
|
||||
coVerify(exactly = 1) { api.getSessionMessages("session-456", any(), any(), any(), any()) }
|
||||
// Local history is exhausted. Both subsequent server pages overlap it completely.
|
||||
repeat(2) {
|
||||
viewModel.loadOlderMessages()
|
||||
advanceUntilIdle()
|
||||
assertEquals(
|
||||
before,
|
||||
viewModel.uiState.value.messages
|
||||
.map { it.id },
|
||||
)
|
||||
}
|
||||
assertFalse(viewModel.uiState.value.hasOlderMessages)
|
||||
coVerify(exactly = 1) { api.getSessionMessages("session-456", 150, 150, "latest", any()) }
|
||||
coVerify(exactly = 1) { api.getSessionMessages("session-456", 150, 300, "latest", any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paging_cacheExhaustedOverlappingServerPageKeepsExistingOrder() =
|
||||
runTest { assertOverlappingHistoryPageKeepsOrder(equalTimestamps = false) }
|
||||
|
||||
@Test
|
||||
fun paging_cacheExhaustedOverlapWithEqualTimestampsKeepsExistingOrder() =
|
||||
runTest { assertOverlappingHistoryPageKeepsOrder(equalTimestamps = true) }
|
||||
|
||||
// ── Attachment open (issue #724) ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.fullBleedItemKeys
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class FullBleedItemKeysTest {
|
||||
@Test
|
||||
fun keysIncludeReasoningToolsAndSystemButSkipEmptyProse() {
|
||||
val turns =
|
||||
groupIntoTurns(
|
||||
listOf(
|
||||
ChatMessage("u", MessageRole.USER, "question"),
|
||||
ChatMessage("empty", MessageRole.ASSISTANT, ""),
|
||||
ChatMessage("t", MessageRole.TOOL, "output"),
|
||||
ChatMessage("a", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"),
|
||||
ChatMessage("s", MessageRole.SYSTEM, "event"),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("user-u", "reasoning-a", "tool-t", "prose-a", "sys-s"), fullBleedItemKeys(turns))
|
||||
assertEquals(mapOf("u" to 0, "a" to 3), messageIdToLazyIndex(turns))
|
||||
assertEquals(mapOf("u" to 2, "a" to 5), messageIdToLazyIndex(turns, leadingItems = 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() {
|
||||
val tool = ChatMessage("live-tool", MessageRole.TOOL, "output")
|
||||
val older =
|
||||
(0..149).map {
|
||||
ChatMessage("old-$it", MessageRole.ASSISTANT, "answer", reasoningText = "thinking")
|
||||
}
|
||||
assertEquals(listOf("tool-live-tool"), fullBleedItemKeys(groupIntoTurns(listOf(tool))))
|
||||
val keys = fullBleedItemKeys(groupIntoTurns(older + tool))
|
||||
assertEquals("reasoning-old-0", keys.first())
|
||||
assertEquals(151, keys.indexOf("tool-live-tool"))
|
||||
}
|
||||
}
|
||||
+171
-1
@@ -104,6 +104,172 @@ class ReadNotificationReviewRegressionTest {
|
||||
assertEquals(listOf("old-completion", "new-completion"), mapped.map { it.completionId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pagingMergePreservesBothIdentitiesAndDoesNotConsumeOlderRepeatedReply() {
|
||||
val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp")
|
||||
val newest = listOf(SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")))
|
||||
val mapped = mapServerMessages("session", newest, 0, true, listOf(live))
|
||||
val hydrated = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true)
|
||||
assertEquals("ws", hydrated.single().id)
|
||||
assertEquals("rest-session-200", hydrated.single().restId)
|
||||
assertEquals("comp", hydrated.single().completionId)
|
||||
val older = listOf(SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")))
|
||||
val page = mapServerMessages("session", older, 150, true, hydrated, isPagingOlder = true)
|
||||
val merged = mergeTranscriptWithLive(page, hydrated, chronological = false, preserveLiveIds = true)
|
||||
assertEquals(listOf("rest-session-100", "ws"), merged.map { it.id })
|
||||
assertEquals(listOf(null, "comp"), merged.map { it.completionId })
|
||||
assertEquals("rest-session-200", merged.last().restId)
|
||||
// A later refresh of ONLY the old row must not reassign the confirmed UUID.
|
||||
assertNull(mapServerMessages("session", older, 0, true, hydrated).single().completionId)
|
||||
assertEquals("comp", mapServerMessages("session", newest, 0, true, hydrated).single().completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun olderPageAndCacheMergeCannotClaimUnconfirmedNotificationByText() {
|
||||
val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp")
|
||||
val older = listOf(SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")))
|
||||
val page = mapServerMessages("session", older, 150, true, listOf(live), isPagingOlder = true)
|
||||
val merged = mergeTranscriptWithLive(page, listOf(live), chronological = false, preserveLiveIds = true)
|
||||
assertEquals(2, merged.size)
|
||||
assertNull(merged.single { it.id == "rest-session-100" }.completionId)
|
||||
assertNull(merged.single { it.id == "ws" }.restId)
|
||||
assertEquals(2, mergeCachedTranscriptPage(page, listOf(live)).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedCompletionMatchesStayOnTheSameLiveKeysAfterMerge() {
|
||||
val live =
|
||||
listOf(
|
||||
ChatMessage(id = "old-ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "old"),
|
||||
ChatMessage(id = "new-ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "new"),
|
||||
)
|
||||
val page =
|
||||
mapServerMessages(
|
||||
"session",
|
||||
listOf(SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done"))),
|
||||
0,
|
||||
true,
|
||||
live,
|
||||
)
|
||||
val merged = mergeTranscriptWithLive(page, live, preserveLiveIds = true)
|
||||
assertEquals("rest-session-200", merged.single { it.id == "new-ws" }.restId)
|
||||
assertNull(merged.single { it.id == "old-ws" }.restId)
|
||||
assertEquals("new", merged.single { it.id == "new-ws" }.completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun durableTargetWinsOverRepeatedLiveTextEvenWithLegacyPositions() {
|
||||
ReplyNotificationTracker.setTargetForTest(
|
||||
ReplyNotificationTarget("default", "session", "comp", 1L, serverMessageId = 100),
|
||||
)
|
||||
val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp")
|
||||
val rows =
|
||||
listOf(
|
||||
SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")),
|
||||
SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")),
|
||||
)
|
||||
for (latest in listOf(true, false)) {
|
||||
val mapped = mapServerMessages("session", rows, 10, latest, listOf(live), context = context)
|
||||
assertEquals(listOf("comp", null), mapped.map { it.completionId })
|
||||
val merged = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true)
|
||||
assertEquals(if (latest) "rest-session-100" else "rest-session-10", merged.single { it.id == "ws" }.restId)
|
||||
assertNull(mapServerMessages("session", rows.takeLast(1), 11, latest, listOf(live)).single().completionId)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exactCacheAndRestOverlapsRetainCompletionWhenPreferredCopyHasNone() {
|
||||
val live =
|
||||
ChatMessage(
|
||||
id = "ws",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Done",
|
||||
restId = "rest-session-200",
|
||||
completionId = "comp",
|
||||
)
|
||||
val rest = live.copy(id = "rest-session-200", restId = null, completionId = null)
|
||||
assertEquals("comp", dedupeCachedMessages(listOf(live, rest)).single().completionId)
|
||||
assertEquals("comp", mergeCachedTranscriptPage(listOf(rest), listOf(live)).single().completionId)
|
||||
assertEquals("comp", mergeTranscriptWithLive(listOf(rest), listOf(live)).single().completionId)
|
||||
val restored = rest.copy(completionId = "comp").toEntity("session").toUiModel()
|
||||
val richWithoutCompletion = live.copy(completionId = null)
|
||||
val merged = mergeCachedTranscriptPage(listOf(restored), listOf(richWithoutCompletion)).single()
|
||||
assertEquals("ws", merged.id)
|
||||
assertEquals("rest-session-200", merged.restId)
|
||||
assertEquals("comp", merged.completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun durableHydrationFoldsMixedCacheEchoIntoRichLiveReply() {
|
||||
ReplyNotificationTracker.setTargetForTest(
|
||||
ReplyNotificationTarget("default", "session", "comp", 1L, serverMessageId = 200),
|
||||
)
|
||||
val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp")
|
||||
val echo = live.copy(id = "rest-session-200", completionId = null)
|
||||
val current = listOf(echo, live)
|
||||
val mapped =
|
||||
mapServerMessages(
|
||||
"session",
|
||||
listOf(SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done"))),
|
||||
0,
|
||||
true,
|
||||
current,
|
||||
)
|
||||
val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true).single()
|
||||
assertEquals("ws", merged.id)
|
||||
assertEquals("rest-session-200", merged.restId)
|
||||
assertEquals("comp", merged.completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cachedNewestDuplicateWithoutDurableIdKeepsCompletionOnNewestLiveReply() {
|
||||
// #129 review: a metadata-free cached echo must not redirect the newest WS completion.
|
||||
ReplyNotificationTracker.setTargetForTest(
|
||||
ReplyNotificationTarget("default", "session", "comp", 1L, serverMessageId = null),
|
||||
)
|
||||
val cachedNewest =
|
||||
ChatMessage(
|
||||
id = "rest-session-200",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Done",
|
||||
completionId = null,
|
||||
)
|
||||
val live =
|
||||
ChatMessage(
|
||||
id = "ws",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Done",
|
||||
completionId = "comp",
|
||||
restId = null,
|
||||
)
|
||||
val current = listOf(cachedNewest, live)
|
||||
val history =
|
||||
listOf(
|
||||
SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")),
|
||||
SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")),
|
||||
)
|
||||
val mapped =
|
||||
mapServerMessages(
|
||||
sessionId = "session",
|
||||
messages = history,
|
||||
offset = 0,
|
||||
latestPaging = true,
|
||||
liveMessages = current,
|
||||
isPagingOlder = false,
|
||||
context = context,
|
||||
)
|
||||
val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true)
|
||||
|
||||
assertNull(mapped.single { it.id == "rest-session-100" }.completionId)
|
||||
assertEquals("comp", mapped.single { it.id == "rest-session-200" }.completionId)
|
||||
assertEquals(2, merged.size)
|
||||
assertEquals(setOf("rest-session-100", "ws"), merged.map { it.id }.toSet())
|
||||
assertEquals(2, merged.count { it.content == "Done" })
|
||||
assertNull(merged.single { it.id == "rest-session-100" }.completionId)
|
||||
assertEquals("rest-session-200", merged.single { it.id == "ws" }.restId)
|
||||
assertEquals("comp", merged.single { it.id == "ws" }.completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun viewingOldCachedTextMustNotClearNewReplyNotification() {
|
||||
postTarget("Done")
|
||||
@@ -383,7 +549,11 @@ class ReadNotificationReviewRegressionTest {
|
||||
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("The result MEDIA:/opt/pic.png")),
|
||||
)
|
||||
val mapped = mapServerMessages("session", history, 0, true, live, isPagingOlder = false)
|
||||
assertEquals("comp-reasoning-media", mapped.single().completionId)
|
||||
assertEquals(listOf("rest-session-1", "rest-session-2"), mapped.map { it.canonicalRestId })
|
||||
assertEquals("thinking step", mapped.first().reasoningText)
|
||||
assertEquals("", mapped.first().content)
|
||||
assertNull(mapped.first().completionId)
|
||||
assertEquals("comp-reasoning-media", mapped.last().completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatHistoryPrefetchTest {
|
||||
@Test
|
||||
fun shortUpwardDragCanReachMovementThresholdDuringFling() {
|
||||
val policy = ChatHistoryPrefetch()
|
||||
assertFalse(policy.onScroll(30, 5f, userInput = true, canLoad = true))
|
||||
assertTrue(policy.onScroll(25, 30f, userInput = false, canLoad = true))
|
||||
assertFalse(policy.onScroll(10, 30f, userInput = false, canLoad = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upwardFlingCanReachPrefetchThresholdAfterDragEnds() {
|
||||
val policy = ChatHistoryPrefetch()
|
||||
assertFalse(policy.onScroll(70, 30f, userInput = true, canLoad = true))
|
||||
assertFalse(policy.onScroll(40, 40f, userInput = false, canLoad = true))
|
||||
assertTrue(policy.onScroll(25, 40f, userInput = false, canLoad = true))
|
||||
assertFalse(policy.onScroll(10, 40f, userInput = false, canLoad = true))
|
||||
policy.endGesture()
|
||||
assertFalse(policy.onScroll(5, 40f, userInput = false, canLoad = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reversingDirectionDisarmsPreviousUpwardIntent() {
|
||||
val policy = ChatHistoryPrefetch()
|
||||
assertFalse(policy.onScroll(70, 30f, userInput = true, canLoad = true))
|
||||
assertFalse(policy.onScroll(50, -40f, userInput = true, canLoad = true))
|
||||
assertFalse(policy.onScroll(25, 40f, userInput = false, canLoad = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialTopAndProgrammaticMovementDoNotFetch() {
|
||||
val policy = ChatHistoryPrefetch()
|
||||
assertFalse(policy.onScroll(0, 0f, userInput = true, canLoad = true))
|
||||
assertFalse(policy.onScroll(0, 100f, userInput = false, canLoad = true))
|
||||
assertFalse(policy.onScroll(0, -100f, userInput = true, canLoad = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefetchStartsTwentyFiveRowsBeforeTopAfterMeaningfulMovement() {
|
||||
val policy = ChatHistoryPrefetch()
|
||||
assertFalse(policy.onScroll(30, 30f, userInput = true, canLoad = true))
|
||||
assertTrue(policy.onScroll(25, 1f, userInput = true, canLoad = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialTopNeedsDeliberateGestureAndRequestsOnlyOnce() {
|
||||
val policy = ChatHistoryPrefetch()
|
||||
assertFalse(policy.onScroll(0, 10f, userInput = true, canLoad = true))
|
||||
assertTrue(policy.onScroll(0, 15f, userInput = true, canLoad = true))
|
||||
repeat(100) {
|
||||
assertFalse(policy.onScroll(0, 50f, userInput = true, canLoad = true))
|
||||
}
|
||||
policy.endGesture()
|
||||
assertTrue(policy.onScroll(0, 25f, userInput = true, canLoad = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadingAndExhaustionSuppressRequestsAndNextGestureCanRetry() {
|
||||
val policy = ChatHistoryPrefetch()
|
||||
assertFalse(policy.onScroll(20, 50f, userInput = true, canLoad = false))
|
||||
policy.endGesture()
|
||||
assertTrue(policy.onScroll(20, 25f, userInput = true, canLoad = true))
|
||||
policy.endGesture()
|
||||
assertTrue(policy.onScroll(20, 25f, userInput = true, canLoad = true))
|
||||
}
|
||||
}
|
||||
@@ -2,29 +2,80 @@ package com.m57.hermescontrol.ui.chat.fakes
|
||||
|
||||
import com.m57.hermescontrol.data.local.ChatMessageDao
|
||||
import com.m57.hermescontrol.data.local.ChatMessageEntity
|
||||
import com.m57.hermescontrol.data.local.canonicalMessageOrder
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentMap
|
||||
|
||||
/**
|
||||
* In-memory [ChatMessageDao] for use in tests.
|
||||
* Uses [ConcurrentHashMap] for thread safety — no manual synchronization needed.
|
||||
* Paging mirrors the DAO's numeric cursor; writes use its default order allocation methods.
|
||||
*/
|
||||
class FakeChatMessageDao : ChatMessageDao {
|
||||
private val messages: ConcurrentMap<String, ChatMessageEntity> = ConcurrentHashMap()
|
||||
|
||||
private val ordering = compareBy<ChatMessageEntity> { it.sortGroup }.thenBy { it.sortOrder }.thenBy { it.id }
|
||||
|
||||
private var insertionSequence = 0L
|
||||
|
||||
var fullSessionReads = 0
|
||||
private set
|
||||
|
||||
var beforeRead: suspend () -> Unit = {}
|
||||
|
||||
override suspend fun sessionExists(sessionId: String): Boolean = messages.values.any { it.sessionId == sessionId }
|
||||
|
||||
override suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity> =
|
||||
messages.values
|
||||
override suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity> {
|
||||
fullSessionReads++
|
||||
beforeRead()
|
||||
return messages.values
|
||||
.filter { it.sessionId == sessionId }
|
||||
.sortedBy { it.timestamp }
|
||||
|
||||
override suspend fun upsert(message: ChatMessageEntity) {
|
||||
messages[message.id] = message
|
||||
.sortedWith(ordering)
|
||||
}
|
||||
|
||||
override suspend fun upsertAll(messageList: List<ChatMessageEntity>) {
|
||||
messageList.forEach { messages[it.id] = it }
|
||||
val pageLimits = mutableListOf<Int>()
|
||||
|
||||
override suspend fun getLatestMessagePage(
|
||||
sessionId: String,
|
||||
limit: Int,
|
||||
): List<ChatMessageEntity> {
|
||||
pageLimits += limit
|
||||
beforeRead()
|
||||
return messages.values
|
||||
.filter { it.sessionId == sessionId }
|
||||
.sortedWith(ordering.reversed())
|
||||
.take(limit)
|
||||
}
|
||||
|
||||
override suspend fun getMessagePage(
|
||||
sessionId: String,
|
||||
beforeGroup: Int,
|
||||
beforeOrder: Long,
|
||||
beforeId: String,
|
||||
limit: Int,
|
||||
): List<ChatMessageEntity> {
|
||||
pageLimits += limit
|
||||
beforeRead()
|
||||
return messages.values
|
||||
.filter {
|
||||
it.sessionId == sessionId &&
|
||||
(
|
||||
it.sortGroup < beforeGroup ||
|
||||
(
|
||||
it.sortGroup == beforeGroup &&
|
||||
(it.sortOrder < beforeOrder || (it.sortOrder == beforeOrder && it.id < beforeId))
|
||||
)
|
||||
)
|
||||
}.sortedWith(ordering.reversed())
|
||||
.take(limit)
|
||||
}
|
||||
|
||||
override suspend fun getMessage(id: String): ChatMessageEntity? = messages[id]
|
||||
|
||||
override suspend fun nextLocalOrder(): Long = insertionSequence + 1
|
||||
|
||||
override suspend fun writeMessage(message: ChatMessageEntity) {
|
||||
if (message.id !in messages) insertionSequence++
|
||||
messages[message.id] = message
|
||||
}
|
||||
|
||||
override suspend fun deleteMessagesForSession(sessionId: String) {
|
||||
@@ -33,14 +84,25 @@ class FakeChatMessageDao : ChatMessageDao {
|
||||
|
||||
/** Direct access for test setup — bypasses the suspend modifier. */
|
||||
fun addMessageDirect(message: ChatMessageEntity) {
|
||||
messages[message.id] = message
|
||||
val existing = messages[message.id]
|
||||
if (existing == null) insertionSequence++
|
||||
val order = canonicalMessageOrder(message.restId ?: message.id, message.sessionId)
|
||||
messages[message.id] =
|
||||
message.copy(
|
||||
sortGroup = if (order != null) 0 else 1,
|
||||
sortOrder = order ?: existing?.sortOrder ?: insertionSequence,
|
||||
)
|
||||
}
|
||||
|
||||
/** Reset all stored messages. */
|
||||
fun clear() {
|
||||
messages.clear()
|
||||
insertionSequence = 0L
|
||||
}
|
||||
|
||||
fun idsForSession(sessionId: String): Set<String> =
|
||||
messages.values.filter { it.sessionId == sessionId }.mapTo(mutableSetOf()) { it.id }
|
||||
|
||||
/** Returns the number of stored messages. */
|
||||
fun count(): Int = messages.size
|
||||
}
|
||||
Reference in new issue
Block a user