fix(chat): keep historical cache out of the live transcript tail (#1253)
This commit is contained in:
@@ -0,0 +1,143 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 10,
|
||||||
|
"identityHash": "1b554126b4f6033e34af91670253c1b9",
|
||||||
|
"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, `message_provenance` TEXT NOT NULL DEFAULT 'UNKNOWN', 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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "messageProvenance",
|
||||||
|
"columnName": "message_provenance",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'UNKNOWN'"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, '1b554126b4f6033e34af91670253c1b9')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
-1
@@ -41,7 +41,7 @@ class ChatMessagePagingDeviceTest {
|
|||||||
Room
|
Room
|
||||||
.databaseBuilder(context, HermesDatabase::class.java, databaseName)
|
.databaseBuilder(context, HermesDatabase::class.java, databaseName)
|
||||||
.setDriver(driver())
|
.setDriver(driver())
|
||||||
.addMigrations(HermesDatabase.MIGRATION_8_9)
|
.addMigrations(HermesDatabase.MIGRATION_8_9, HermesDatabase.MIGRATION_9_10)
|
||||||
.build()
|
.build()
|
||||||
repository = ChatPersistenceRepository(database.chatMessageDao())
|
repository = ChatPersistenceRepository(database.chatMessageDao())
|
||||||
}
|
}
|
||||||
@@ -234,6 +234,29 @@ class ChatMessagePagingDeviceTest {
|
|||||||
assertPagingIndexUsed()
|
assertPagingIndexUsed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun migration9to10PreservesAmbiguousUnsentUuidUserAsUnknown() =
|
||||||
|
runBlocking {
|
||||||
|
val unsent =
|
||||||
|
ChatMessageEntity(
|
||||||
|
id = "pending-before-upgrade",
|
||||||
|
sessionId = "session",
|
||||||
|
role = "USER",
|
||||||
|
content = "may not have reached the server",
|
||||||
|
timestamp = 1L,
|
||||||
|
sortOrder = 1L,
|
||||||
|
)
|
||||||
|
createVersion9(listOf(unsent))
|
||||||
|
openDatabase()
|
||||||
|
val restored = database.chatMessageDao().getMessage(unsent.id)!!
|
||||||
|
assertEquals("UNKNOWN", restored.messageProvenance)
|
||||||
|
assertEquals(
|
||||||
|
com.m57.hermescontrol.ui.chat.MessageProvenance.UNKNOWN,
|
||||||
|
restored.toUiModel().messageProvenance,
|
||||||
|
)
|
||||||
|
assertEquals(unsent.content, restored.content)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun confirmedAliasPreservesLivePayloadAndCanonicalOrderAcrossReopen() =
|
fun confirmedAliasPreservesLivePayloadAndCanonicalOrderAcrossReopen() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
@@ -350,6 +373,63 @@ class ChatMessagePagingDeviceTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build the exported v9 schema so Room validates the real non-destructive v9 -> v10 path. */
|
||||||
|
private fun createVersion9(rows: List<ChatMessageEntity>) {
|
||||||
|
database.close()
|
||||||
|
context.deleteDatabase(databaseName)
|
||||||
|
val schema =
|
||||||
|
InstrumentationRegistry
|
||||||
|
.getInstrumentation()
|
||||||
|
.context.assets
|
||||||
|
.open("com.m57.hermescontrol.data.local.HermesDatabase/9.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, rest_id, sort_group, sort_order) 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)
|
||||||
|
row.restId?.let { insert.bindText(15, it) } ?: insert.bindNull(15)
|
||||||
|
insert.bindLong(16, row.sortGroup.toLong())
|
||||||
|
insert.bindLong(17, row.sortOrder)
|
||||||
|
insert.step()
|
||||||
|
insert.reset()
|
||||||
|
insert.clearBindings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.execSQL("PRAGMA user_version = 9")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun assertPagingIndexUsed() {
|
private fun assertPagingIndexUsed() {
|
||||||
database.close()
|
database.close()
|
||||||
driver().open(context.getDatabasePath(databaseName).absolutePath).use { connection ->
|
driver().open(context.getDatabasePath(databaseName).absolutePath).use { connection ->
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ data class ChatMessageEntity(
|
|||||||
val sortGroup: Int = 1,
|
val sortGroup: Int = 1,
|
||||||
@ColumnInfo(name = "sort_order", defaultValue = "0")
|
@ColumnInfo(name = "sort_order", defaultValue = "0")
|
||||||
val sortOrder: Long = 0,
|
val sortOrder: Long = 0,
|
||||||
|
@ColumnInfo(name = "message_provenance", defaultValue = "'UNKNOWN'")
|
||||||
|
val messageProvenance: String = "UNKNOWN",
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun ChatMessageEntity.isSessionStartMarker(): Boolean =
|
internal fun ChatMessageEntity.isSessionStartMarker(): Boolean =
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.m57.hermescontrol.data.local
|
package com.m57.hermescontrol.data.local
|
||||||
|
|
||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
|
import com.m57.hermescontrol.ui.chat.MessageProvenance
|
||||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||||
|
|
||||||
@@ -40,6 +41,9 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
|
|||||||
completionId = completionId,
|
completionId = completionId,
|
||||||
restId = restId,
|
restId = restId,
|
||||||
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 && !isSessionStartMarker() },
|
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 && !isSessionStartMarker() },
|
||||||
|
messageProvenance =
|
||||||
|
MessageProvenance.entries.firstOrNull { it.name == messageProvenance }
|
||||||
|
?: MessageProvenance.UNKNOWN,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||||
@@ -59,4 +63,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
|||||||
tps = tps,
|
tps = tps,
|
||||||
completionId = completionId,
|
completionId = completionId,
|
||||||
restId = restId,
|
restId = restId,
|
||||||
|
messageProvenance = messageProvenance.name,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import java.io.File
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [ChatMessageEntity::class],
|
entities = [ChatMessageEntity::class],
|
||||||
version = 9,
|
version = 10,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
abstract class HermesDatabase : RoomDatabase() {
|
abstract class HermesDatabase : RoomDatabase() {
|
||||||
@@ -115,6 +115,17 @@ abstract class HermesDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_9_10: Migration =
|
||||||
|
object : Migration(9, 10) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
// v9 cannot prove whether a UUID-only row reached the server. Preserve that
|
||||||
|
// ambiguity; only new outgoing prompts record LOCAL_PENDING before submit.
|
||||||
|
connection.execSQL(
|
||||||
|
"ALTER TABLE chat_messages ADD COLUMN message_provenance TEXT NOT NULL DEFAULT 'UNKNOWN'",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun get(context: Context): HermesDatabase =
|
suspend fun get(context: Context): HermesDatabase =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
instance?.let { return@withContext it }
|
instance?.let { return@withContext it }
|
||||||
@@ -152,6 +163,7 @@ abstract class HermesDatabase : RoomDatabase() {
|
|||||||
MIGRATION_6_7,
|
MIGRATION_6_7,
|
||||||
MIGRATION_7_8,
|
MIGRATION_7_8,
|
||||||
MIGRATION_8_9,
|
MIGRATION_8_9,
|
||||||
|
MIGRATION_9_10,
|
||||||
).fallbackToDestructiveMigration(false)
|
).fallbackToDestructiveMigration(false)
|
||||||
.build()
|
.build()
|
||||||
.also { instance = it }
|
.also { instance = it }
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ data class ToolOutputRiskData(
|
|||||||
val redacted: Boolean,
|
val redacted: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Durable evidence about a locally-created row; UNKNOWN must not be treated as proof of delivery. */
|
||||||
|
enum class MessageProvenance {
|
||||||
|
UNKNOWN,
|
||||||
|
LOCAL_PENDING,
|
||||||
|
}
|
||||||
|
|
||||||
data class ChatMessage(
|
data class ChatMessage(
|
||||||
val id: String = UUID.randomUUID().toString(),
|
val id: String = UUID.randomUUID().toString(),
|
||||||
val role: MessageRole,
|
val role: MessageRole,
|
||||||
@@ -91,12 +97,20 @@ data class ChatMessage(
|
|||||||
val restId: String? = null,
|
val restId: String? = null,
|
||||||
/** Cache insertion sequence for unconfirmed local rows; null before first persistence. */
|
/** Cache insertion sequence for unconfirmed local rows; null before first persistence. */
|
||||||
val localOrder: Long? = null,
|
val localOrder: Long? = null,
|
||||||
|
/** Persisted before prompt submission so process death cannot turn an unsent prompt into old history. */
|
||||||
|
val messageProvenance: MessageProvenance = MessageProvenance.UNKNOWN,
|
||||||
|
/** Read from history, not observed live in this view. Never persisted as delivery state. */
|
||||||
|
val isHistoricalCache: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Cached REST rows already carry their canonical identity in the persisted primary key. */
|
/** Cached REST rows already carry their canonical identity in the persisted primary key. */
|
||||||
internal val ChatMessage.canonicalRestId: String?
|
internal val ChatMessage.canonicalRestId: String?
|
||||||
get() = restId ?: id.takeIf { it.startsWith("rest-") }
|
get() = restId ?: id.takeIf { it.startsWith("rest-") }
|
||||||
|
|
||||||
|
/** A persisted RUNNING snapshot is not evidence of current tool activity. */
|
||||||
|
internal val ChatMessage.isToolRunning: Boolean
|
||||||
|
get() = toolStatus == ToolStatus.RUNNING && !isHistoricalCache
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single live transcript log entry for subagent execution.
|
* Single live transcript log entry for subagent execution.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -227,6 +227,17 @@ internal fun mapServerMessages(
|
|||||||
val matches = matchTranscriptMessages(mappedTools, liveTools)
|
val matches = matchTranscriptMessages(mappedTools, liveTools)
|
||||||
val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] }
|
val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] }
|
||||||
return mapped.map { message ->
|
return mapped.map { message ->
|
||||||
toolsById[message.id]?.copy(restId = message.canonicalRestId) ?: message
|
val local = toolsById[message.id]
|
||||||
|
if (local?.isHistoricalCache == true && local.toolStatus == ToolStatus.RUNNING) {
|
||||||
|
// A canonical tool-result row settles a cached tool.start, not the reverse.
|
||||||
|
local.copy(
|
||||||
|
restId = message.canonicalRestId,
|
||||||
|
content = message.content,
|
||||||
|
toolStatus = ToolStatus.COMPLETED,
|
||||||
|
isHistoricalCache = false,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
local?.copy(restId = message.canonicalRestId) ?: message
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,13 +341,35 @@ internal fun mergeCachedTranscriptPage(
|
|||||||
incoming
|
incoming
|
||||||
.mapIndexedNotNull { index, message ->
|
.mapIndexedNotNull { index, message ->
|
||||||
val match = matches[index] ?: return@mapIndexedNotNull null
|
val match = matches[index] ?: return@mapIndexedNotNull null
|
||||||
val rich = if (match.id.startsWith("rest-") && !message.id.startsWith("rest-")) message else match
|
val rich =
|
||||||
|
when {
|
||||||
|
// A late historical snapshot may contribute safe metadata, but it must
|
||||||
|
// never replace a canonical server payload/status already on screen.
|
||||||
|
message.isHistoricalCache && match.canonicalRestId != null -> {
|
||||||
|
match.copy(
|
||||||
|
toolName = match.toolName ?: message.toolName,
|
||||||
|
toolCallId = match.toolCallId.ifBlank { message.toolCallId },
|
||||||
|
attachments = match.attachments ?: message.attachments,
|
||||||
|
finishTimestamp = match.finishTimestamp ?: message.finishTimestamp,
|
||||||
|
tokenCount = match.tokenCount ?: message.tokenCount,
|
||||||
|
tps = match.tps ?: message.tps,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
match.id.startsWith("rest-") && !message.id.startsWith("rest-") -> {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
match
|
||||||
|
}
|
||||||
|
}
|
||||||
match.id to
|
match.id to
|
||||||
rich.copy(
|
rich.copy(
|
||||||
id = match.id,
|
id = match.id,
|
||||||
restId = message.canonicalRestId ?: match.canonicalRestId,
|
restId = match.canonicalRestId ?: message.canonicalRestId,
|
||||||
completionId = match.completionId ?: message.completionId,
|
completionId = match.completionId ?: message.completionId,
|
||||||
displayKind = message.displayKind ?: match.displayKind,
|
displayKind = match.displayKind ?: message.displayKind,
|
||||||
)
|
)
|
||||||
}.toMap()
|
}.toMap()
|
||||||
val resolvedOrders =
|
val resolvedOrders =
|
||||||
@@ -381,6 +403,7 @@ internal fun mergeTranscriptWithLive(
|
|||||||
match.copy(
|
match.copy(
|
||||||
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
||||||
displayKind = message.displayKind ?: match.displayKind,
|
displayKind = message.displayKind ?: match.displayKind,
|
||||||
|
isHistoricalCache = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,11 +421,12 @@ internal fun mergeTranscriptWithLive(
|
|||||||
displayKind = message.displayKind ?: match.displayKind,
|
displayKind = message.displayKind ?: match.displayKind,
|
||||||
tokenCount = message.tokenCount ?: match.tokenCount,
|
tokenCount = message.tokenCount ?: match.tokenCount,
|
||||||
completionId = message.completionId ?: match.completionId,
|
completionId = message.completionId ?: match.completionId,
|
||||||
|
isHistoricalCache = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
message.copy(completionId = message.completionId ?: match?.completionId)
|
message.copy(completionId = message.completionId ?: match?.completionId, isHistoricalCache = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,6 +455,8 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
|||||||
resolvedOrders: Map<String, Long>,
|
resolvedOrders: Map<String, Long>,
|
||||||
): List<ChatMessage> {
|
): List<ChatMessage> {
|
||||||
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
|
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
|
||||||
|
// Session-start markers use -1 and must remain before unresolved cached history.
|
||||||
|
val beforeCanonical = (mapNotNull { it.canonicalOrder }.filter { it >= 0L }.minOrNull() ?: 0L) - 1L
|
||||||
var precedingCanonical: Long? = null
|
var precedingCanonical: Long? = null
|
||||||
var hasPendingPredecessor = false
|
var hasPendingPredecessor = false
|
||||||
var pendingLocalOrder: Long? = null
|
var pendingLocalOrder: Long? = null
|
||||||
@@ -451,6 +477,10 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
|||||||
?: latestCanonical
|
?: latestCanonical
|
||||||
}
|
}
|
||||||
if (hasPendingPredecessor) pendingOrderByLocal[message.id] = pendingLocalOrder ?: Long.MAX_VALUE
|
if (hasPendingPredecessor) pendingOrderByLocal[message.id] = pendingLocalOrder ?: Long.MAX_VALUE
|
||||||
|
} else if (message.isHistoricalCache) {
|
||||||
|
// Room groups UUID-only rows after all confirmed rows; that predecessor is
|
||||||
|
// not a chronological anchor. Keep unresolved history before the server window.
|
||||||
|
localAnchors[message.id] = beforeCanonical
|
||||||
} else {
|
} else {
|
||||||
hasPendingPredecessor = true
|
hasPendingPredecessor = true
|
||||||
pendingLocalOrder = message.localOrder
|
pendingLocalOrder = message.localOrder
|
||||||
|
|||||||
@@ -1832,6 +1832,7 @@ class ChatViewModel(
|
|||||||
content = text,
|
content = text,
|
||||||
attachments = if (attachments.isNotEmpty()) attachments else null,
|
attachments = if (attachments.isNotEmpty()) attachments else null,
|
||||||
tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 },
|
tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 },
|
||||||
|
messageProvenance = MessageProvenance.LOCAL_PENDING,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update UI immediately
|
// Update UI immediately
|
||||||
@@ -1894,6 +1895,7 @@ class ChatViewModel(
|
|||||||
content = text,
|
content = text,
|
||||||
attachments = if (attachments.isNotEmpty()) attachments else null,
|
attachments = if (attachments.isNotEmpty()) attachments else null,
|
||||||
tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 },
|
tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 },
|
||||||
|
messageProvenance = MessageProvenance.LOCAL_PENDING,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Upload attachments then submit prompt
|
// Upload attachments then submit prompt
|
||||||
@@ -3256,7 +3258,29 @@ class ChatViewModel(
|
|||||||
val snapshot = _uiState.value
|
val snapshot = _uiState.value
|
||||||
val computed =
|
val computed =
|
||||||
withContext(historyDispatcher) {
|
withContext(historyDispatcher) {
|
||||||
val page = mapPage(snapshot.messages)
|
val mapped = mapPage(snapshot.messages)
|
||||||
|
val currentById = snapshot.messages.associateBy { it.id }
|
||||||
|
// Durable provenance distinguishes new unsent prompts after process death.
|
||||||
|
// Legacy UUID-only USER rows remain conservatively unconfirmed: UNKNOWN is
|
||||||
|
// not evidence that the server delivered them.
|
||||||
|
val page =
|
||||||
|
if (cached) {
|
||||||
|
mapped.map { message ->
|
||||||
|
message.copy(
|
||||||
|
isHistoricalCache =
|
||||||
|
when {
|
||||||
|
currentById[message.id]?.isHistoricalCache == false -> false
|
||||||
|
message.isPermanentlyLocal() -> false
|
||||||
|
message.messageProvenance == MessageProvenance.LOCAL_PENDING -> false
|
||||||
|
message.canonicalRestId != null -> true
|
||||||
|
message.role == MessageRole.USER -> false
|
||||||
|
else -> true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mapped
|
||||||
|
}
|
||||||
val merged =
|
val merged =
|
||||||
if (cached) {
|
if (cached) {
|
||||||
mergeCachedTranscriptPage(page, snapshot.messages)
|
mergeCachedTranscriptPage(page, snapshot.messages)
|
||||||
|
|||||||
@@ -681,6 +681,7 @@ object ChatWsEventReducer {
|
|||||||
messages[toolIdx].copy(
|
messages[toolIdx].copy(
|
||||||
toolStatus = ToolStatus.COMPLETED,
|
toolStatus = ToolStatus.COMPLETED,
|
||||||
content = contentJson,
|
content = contentJson,
|
||||||
|
isHistoricalCache = false,
|
||||||
)
|
)
|
||||||
messages[toolIdx] = updated
|
messages[toolIdx] = updated
|
||||||
|
|
||||||
@@ -928,7 +929,11 @@ object ChatWsEventReducer {
|
|||||||
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
|
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
|
||||||
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
messages[toolIdx] = messages[toolIdx].copy(progressPreview = event.preview ?: "")
|
messages[toolIdx] =
|
||||||
|
messages[toolIdx].copy(
|
||||||
|
progressPreview = event.preview ?: "",
|
||||||
|
isHistoricalCache = false,
|
||||||
|
)
|
||||||
return ReducerResult(
|
return ReducerResult(
|
||||||
state = state.copy(messages = messages),
|
state = state.copy(messages = messages),
|
||||||
streamingState = streamingState,
|
streamingState = streamingState,
|
||||||
@@ -944,14 +949,14 @@ object ChatWsEventReducer {
|
|||||||
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
|
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
|
||||||
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
messages[toolIdx] = messages[toolIdx].copy(progressPreview = "")
|
messages[toolIdx] = messages[toolIdx].copy(progressPreview = "", isHistoricalCache = false)
|
||||||
return ReducerResult(
|
return ReducerResult(
|
||||||
state = state.copy(messages = messages),
|
state = state.copy(messages = messages),
|
||||||
streamingState = streamingState,
|
streamingState = streamingState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Match stable tool IDs first, with name matching for legacy events. */
|
/** Match an explicit stable ID exclusively; use name matching only when legacy events omit the ID. */
|
||||||
private fun findToolIndex(
|
private fun findToolIndex(
|
||||||
messages: List<ChatMessage>,
|
messages: List<ChatMessage>,
|
||||||
name: String?,
|
name: String?,
|
||||||
@@ -965,7 +970,8 @@ object ChatWsEventReducer {
|
|||||||
it.toolCallId == toolId &&
|
it.toolCallId == toolId &&
|
||||||
it.toolStatus == status
|
it.toolStatus == status
|
||||||
}
|
}
|
||||||
if (byId >= 0) return byId
|
// An explicit identity must never claim a different historical invocation.
|
||||||
|
return byId
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages.indexOfLast {
|
return messages.indexOfLast {
|
||||||
|
|||||||
@@ -143,8 +143,8 @@ internal fun ToolBubble(
|
|||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
|
||||||
val view =
|
val view =
|
||||||
remember(message.content, message.toolName, message.toolStatus) {
|
remember(message.content, message.toolName, message.isToolRunning) {
|
||||||
parseToolOutput(message.content, message.toolName, message.toolStatus == ToolStatus.RUNNING)
|
parseToolOutput(message.content, message.toolName, message.isToolRunning)
|
||||||
}
|
}
|
||||||
val config = ToolSchemaRegistry.getDisplayConfig(message.toolName)
|
val config = ToolSchemaRegistry.getDisplayConfig(message.toolName)
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ internal fun ToolBubble(
|
|||||||
HeaderRow(message, config, contentColor, statusColors)
|
HeaderRow(message, config, contentColor, statusColors)
|
||||||
|
|
||||||
// ── Tool progress preview (tool.progress) ──
|
// ── Tool progress preview (tool.progress) ──
|
||||||
if (message.toolStatus == ToolStatus.RUNNING && !message.progressPreview.isNullOrEmpty()) {
|
if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = message.progressPreview,
|
text = message.progressPreview,
|
||||||
style =
|
style =
|
||||||
@@ -534,7 +534,7 @@ private fun HeaderRow(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
// Status icon or spinner
|
// Status icon or spinner
|
||||||
if (message.toolStatus == ToolStatus.RUNNING) {
|
if (message.isToolRunning) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(14.dp),
|
modifier = Modifier.size(14.dp),
|
||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
|
|||||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||||
import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry
|
import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry
|
||||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
|
||||||
import com.m57.hermescontrol.ui.chat.components.TypingIndicator
|
import com.m57.hermescontrol.ui.chat.components.TypingIndicator
|
||||||
|
import com.m57.hermescontrol.ui.chat.isToolRunning
|
||||||
import com.m57.hermescontrol.ui.chat.isUserTurnBoundary
|
import com.m57.hermescontrol.ui.chat.isUserTurnBoundary
|
||||||
|
|
||||||
/** Live status for an agent turn before visible assistant prose is available. */
|
/** Live status for an agent turn before visible assistant prose is available. */
|
||||||
@@ -61,7 +61,7 @@ internal fun deriveAgentStatus(
|
|||||||
.asReversed()
|
.asReversed()
|
||||||
.firstOrNull {
|
.firstOrNull {
|
||||||
it.role == MessageRole.TOOL &&
|
it.role == MessageRole.TOOL &&
|
||||||
it.toolStatus == ToolStatus.RUNNING
|
it.isToolRunning
|
||||||
}
|
}
|
||||||
val hasVisibleStreamingContent = streamingState.streamingMessage?.content?.isNotBlank() == true
|
val hasVisibleStreamingContent = streamingState.streamingMessage?.content?.isNotBlank() == true
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.m57.hermescontrol.data.local
|
package com.m57.hermescontrol.data.local
|
||||||
|
|
||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
|
import com.m57.hermescontrol.ui.chat.MessageProvenance
|
||||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -187,6 +188,32 @@ class ChatMessageMapperTest {
|
|||||||
assertEquals(MessageRole.USER, roundTripped.role)
|
assertEquals(MessageRole.USER, roundTripped.role)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTripPreservesLocalPendingProvenance() {
|
||||||
|
val roundTripped =
|
||||||
|
ChatMessage(
|
||||||
|
id = "pending",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "not submitted",
|
||||||
|
messageProvenance = MessageProvenance.LOCAL_PENDING,
|
||||||
|
).toEntity("s").toUiModel()
|
||||||
|
assertEquals(MessageProvenance.LOCAL_PENDING, roundTripped.messageProvenance)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unknownPersistedProvenanceDoesNotInventDeliveryEvidence() {
|
||||||
|
val entity =
|
||||||
|
ChatMessageEntity(
|
||||||
|
id = "legacy",
|
||||||
|
sessionId = "s",
|
||||||
|
role = "USER",
|
||||||
|
content = "ambiguous",
|
||||||
|
timestamp = 1L,
|
||||||
|
messageProvenance = "future-value",
|
||||||
|
)
|
||||||
|
assertEquals(MessageProvenance.UNKNOWN, entity.toUiModel().messageProvenance)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun roundTripWithoutDisplayKindStaysNull() {
|
fun roundTripWithoutDisplayKindStaysNull() {
|
||||||
val ui =
|
val ui =
|
||||||
|
|||||||
@@ -97,4 +97,16 @@ class HermesDatabaseMigrationsTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun migration9to10_addsDurableProvenanceWithoutInventingDeliveryState() =
|
||||||
|
runBlocking {
|
||||||
|
val connection = mockk<SQLiteConnection>(relaxed = true)
|
||||||
|
HermesDatabase.MIGRATION_9_10.migrate(connection)
|
||||||
|
verify(exactly = 1) {
|
||||||
|
connection.execSQL(
|
||||||
|
"ALTER TABLE chat_messages ADD COLUMN message_provenance TEXT NOT NULL DEFAULT 'UNKNOWN'",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ChatCachedHistoryTest {
|
||||||
|
private val historicalTool =
|
||||||
|
ChatMessage(
|
||||||
|
id = "cached-tool",
|
||||||
|
role = MessageRole.TOOL,
|
||||||
|
content = "{}",
|
||||||
|
toolName = "terminal",
|
||||||
|
toolCallId = "call-old",
|
||||||
|
toolStatus = ToolStatus.RUNNING,
|
||||||
|
isHistoricalCache = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun exactToolIdPromotesOnlyItsHistoricalIdentity() {
|
||||||
|
val other = historicalTool.copy(id = "other", toolCallId = "call-other")
|
||||||
|
val state = ChatUiState(currentSessionId = "s", messages = listOf(historicalTool, other))
|
||||||
|
val events =
|
||||||
|
listOf(
|
||||||
|
WsEvent.ToolProgress(name = "terminal", preview = "working", sessionId = "s", toolId = "call-old"),
|
||||||
|
WsEvent.ToolGenerating(name = "terminal", sessionId = "s", toolId = "call-old"),
|
||||||
|
WsEvent.ToolComplete(name = "terminal", data = mapOf("tool_id" to "call-old"), sessionId = "s"),
|
||||||
|
)
|
||||||
|
events.forEach { event ->
|
||||||
|
val result = ChatWsEventReducer.reduce(state, StreamingState(), event, "s")
|
||||||
|
assertFalse(
|
||||||
|
"$event must supersede historical provenance",
|
||||||
|
result.state.messages
|
||||||
|
.first()
|
||||||
|
.isHistoricalCache,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
result.state.messages
|
||||||
|
.last()
|
||||||
|
.isHistoricalCache,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun toolProgressWithNonmatchingExplicitIdDoesNotPromoteHistoricalSameNameCall() {
|
||||||
|
val state = ChatUiState(currentSessionId = "s", messages = listOf(historicalTool))
|
||||||
|
|
||||||
|
val result =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
state,
|
||||||
|
StreamingState(),
|
||||||
|
WsEvent.ToolProgress(name = "terminal", preview = "new call", sessionId = "s", toolId = "call-new"),
|
||||||
|
"s",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(historicalTool), result.state.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun toolCompleteWithNonmatchingExplicitIdDoesNotOverwriteHistoricalSameNameCall() {
|
||||||
|
val state = ChatUiState(currentSessionId = "s", messages = listOf(historicalTool))
|
||||||
|
|
||||||
|
val result =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
state,
|
||||||
|
StreamingState(),
|
||||||
|
WsEvent.ToolComplete(
|
||||||
|
name = "terminal",
|
||||||
|
data = mapOf("tool_id" to "call-new", "output" to "new call"),
|
||||||
|
sessionId = "s",
|
||||||
|
),
|
||||||
|
"s",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(historicalTool), result.state.messages)
|
||||||
|
assertTrue(result.effects.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun legacyIdlessToolEventsStillPromoteHistoricalSameNameCall() {
|
||||||
|
val state = ChatUiState(currentSessionId = "s", messages = listOf(historicalTool))
|
||||||
|
val events =
|
||||||
|
listOf(
|
||||||
|
WsEvent.ToolProgress(name = "terminal", preview = "legacy", sessionId = "s"),
|
||||||
|
WsEvent.ToolComplete(name = "terminal", data = mapOf("output" to "legacy"), sessionId = "s"),
|
||||||
|
)
|
||||||
|
|
||||||
|
events.forEach { event ->
|
||||||
|
val result = ChatWsEventReducer.reduce(state, StreamingState(), event, "s")
|
||||||
|
assertFalse(
|
||||||
|
"$event must retain the legacy name fallback",
|
||||||
|
result.state.messages
|
||||||
|
.single()
|
||||||
|
.isHistoricalCache,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun lateCacheCannotDemoteAnObservedLiveIdentity() {
|
||||||
|
val live = historicalTool.copy(isHistoricalCache = false, progressPreview = "new progress")
|
||||||
|
val merged = mergeCachedTranscriptPage(listOf(historicalTool), listOf(live))
|
||||||
|
assertEquals(listOf(live), merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cachedSessionStartMarkerStaysFirstBeforeHistoricalUuidAndCanonicalRestWindow() {
|
||||||
|
val start =
|
||||||
|
ChatMessage(
|
||||||
|
id = "cached-session-start",
|
||||||
|
role = MessageRole.SYSTEM,
|
||||||
|
content = "Session created",
|
||||||
|
isHistoricalCache = true,
|
||||||
|
)
|
||||||
|
val historicalUser =
|
||||||
|
ChatMessage(
|
||||||
|
id = "5df43b6f-98ea-46db-9fc4-d1e4cf11d43d",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "older cached prompt",
|
||||||
|
isHistoricalCache = true,
|
||||||
|
)
|
||||||
|
val canonical = ChatMessage(id = "rest-s-0", role = MessageRole.ASSISTANT, content = "canonical reply")
|
||||||
|
|
||||||
|
val merged = mergeTranscriptWithLive(listOf(canonical), listOf(start, historicalUser))
|
||||||
|
|
||||||
|
assertEquals(listOf(start.id, historicalUser.id, canonical.id), merged.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun historicalRowsPrecedeCanonicalWindowWithoutChangingLiveTailOrAttachments() {
|
||||||
|
val attachment = Attachment("content://test/file", "file.txt", "text/plain")
|
||||||
|
val old =
|
||||||
|
ChatMessage(
|
||||||
|
id = "cached-user",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "old",
|
||||||
|
timestamp = Long.MAX_VALUE,
|
||||||
|
attachments = listOf(attachment),
|
||||||
|
isHistoricalCache = true,
|
||||||
|
)
|
||||||
|
val pending = old.copy(id = "pending", content = "pending", isHistoricalCache = false)
|
||||||
|
val recent = ChatMessage(id = "rest-s-100", role = MessageRole.ASSISTANT, content = "recent", timestamp = 1)
|
||||||
|
val live = historicalTool.copy(id = "live-tool", toolCallId = "call-live", isHistoricalCache = false)
|
||||||
|
val resume = ChatMessage(id = "resume", role = MessageRole.SYSTEM, content = "Session resumed")
|
||||||
|
val merged = mergeTranscriptWithLive(listOf(recent), listOf(old, historicalTool, pending, live, resume))
|
||||||
|
assertEquals(listOf(old.id, historicalTool.id, recent.id, pending.id, live.id, resume.id), merged.map { it.id })
|
||||||
|
assertEquals(listOf(attachment), merged.first().attachments)
|
||||||
|
assertEquals(listOf(attachment), merged.single { it.id == pending.id }.attachments)
|
||||||
|
assertEquals(merged, mergeTranscriptWithLive(listOf(recent), merged))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,63 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ChatPagingMergeTest {
|
class ChatPagingMergeTest {
|
||||||
|
@Test
|
||||||
|
fun lateCachedRunningToolCannotReplaceCanonicalServerResult() {
|
||||||
|
val canonical =
|
||||||
|
ChatMessage(
|
||||||
|
id = "rest-s-42",
|
||||||
|
role = MessageRole.TOOL,
|
||||||
|
content = """{"output":"server result","exit_code":0}""",
|
||||||
|
toolCallId = "call-42",
|
||||||
|
toolStatus = ToolStatus.COMPLETED,
|
||||||
|
)
|
||||||
|
val cached =
|
||||||
|
ChatMessage(
|
||||||
|
id = "cached-tool",
|
||||||
|
role = MessageRole.TOOL,
|
||||||
|
content = """{"name":"terminal","args":{"command":"pwd"}}""",
|
||||||
|
toolName = "terminal",
|
||||||
|
toolCallId = "call-42",
|
||||||
|
toolStatus = ToolStatus.RUNNING,
|
||||||
|
isHistoricalCache = true,
|
||||||
|
tokenCount = 7,
|
||||||
|
tps = 2.5,
|
||||||
|
finishTimestamp = 123L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val merged = mergeCachedTranscriptPage(listOf(cached), listOf(canonical)).single()
|
||||||
|
|
||||||
|
assertEquals(canonical.id, merged.id)
|
||||||
|
assertEquals(canonical.content, merged.content)
|
||||||
|
assertEquals(ToolStatus.COMPLETED, merged.toolStatus)
|
||||||
|
assertEquals("terminal", merged.toolName)
|
||||||
|
assertEquals("call-42", merged.toolCallId)
|
||||||
|
assertEquals(cached.tokenCount, merged.tokenCount)
|
||||||
|
assertEquals(cached.tps, merged.tps)
|
||||||
|
assertEquals(cached.finishTimestamp, merged.finishTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canonicalResultSettlesHistoricalRunningToolWithServerOutput() {
|
||||||
|
val cached =
|
||||||
|
ChatMessage(
|
||||||
|
id = "old-tool",
|
||||||
|
role = MessageRole.TOOL,
|
||||||
|
content = """{"name":"terminal","args":{"command":"pwd"}}""",
|
||||||
|
toolName = "terminal",
|
||||||
|
toolCallId = "call-42",
|
||||||
|
toolStatus = ToolStatus.RUNNING,
|
||||||
|
isHistoricalCache = true,
|
||||||
|
)
|
||||||
|
val page = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(cached))
|
||||||
|
val merged = mergeTranscriptWithLive(page, listOf(cached), preserveLiveIds = true).single()
|
||||||
|
assertEquals(cached.id, merged.id)
|
||||||
|
assertEquals("rest-s-42", merged.canonicalRestId)
|
||||||
|
assertEquals(ToolStatus.COMPLETED, merged.toolStatus)
|
||||||
|
assertTrue(merged.content.contains("exit_code"))
|
||||||
|
assertTrue(!merged.isHistoricalCache)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun confirmedLiveOccurrenceCannotConsumeEarlierIdenticalPage() {
|
fun confirmedLiveOccurrenceCannotConsumeEarlierIdenticalPage() {
|
||||||
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) {
|
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) {
|
||||||
|
|||||||
@@ -5389,6 +5389,200 @@ class ChatViewModelTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun paging_staleCacheBeforeRestDoesNotBecomeTheLatestTail() =
|
||||||
|
runTest {
|
||||||
|
val cachedIds = seedPagingCache(3)
|
||||||
|
val response = CompletableDeferred<retrofit2.Response<SessionMessagesResponse>>()
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
coEvery {
|
||||||
|
ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
|
} coAnswers { response.await() }
|
||||||
|
|
||||||
|
viewModel.switchSession("session-456")
|
||||||
|
runCurrent()
|
||||||
|
assertEquals(
|
||||||
|
cachedIds,
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
|
response.complete(pagingResponse(100..102))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val expected = cachedIds + (100..102).map { "rest-session-456-$it" }
|
||||||
|
assertEquals(
|
||||||
|
expected,
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
|
viewModel.syncCurrentSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(
|
||||||
|
expected,
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
|
assertTrue(fakeRepo.dao.idsForSession("session-456").containsAll(cachedIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun paging_staleCacheAfterRestDoesNotBecomeTheLatestTail() =
|
||||||
|
runTest {
|
||||||
|
val cachedIds = seedPagingCache(3)
|
||||||
|
val cacheRead = CompletableDeferred<Unit>()
|
||||||
|
fakeRepo.dao.beforeRead = { cacheRead.await() }
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
coEvery {
|
||||||
|
ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
|
} returns pagingResponse(100..102)
|
||||||
|
|
||||||
|
viewModel.switchSession("session-456")
|
||||||
|
runCurrent()
|
||||||
|
cacheRead.complete(Unit)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
cachedIds + (100..102).map { "rest-session-456-$it" },
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun paging_coldRestartPendingLocalRemainsAfterCanonicalWindow() =
|
||||||
|
runTest {
|
||||||
|
val pending =
|
||||||
|
ChatMessage(
|
||||||
|
id = "pending-local",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "not delivered yet",
|
||||||
|
messageProvenance = MessageProvenance.LOCAL_PENDING,
|
||||||
|
)
|
||||||
|
fakeRepo.persistMessage(pending, "session-456")
|
||||||
|
val cacheRead = CompletableDeferred<Unit>()
|
||||||
|
fakeRepo.dao.beforeRead = { cacheRead.await() }
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
coEvery {
|
||||||
|
ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
|
} returns pagingResponse(100..102)
|
||||||
|
|
||||||
|
viewModel.switchSession("session-456")
|
||||||
|
runCurrent()
|
||||||
|
cacheRead.complete(Unit)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
(100..102).map { "rest-session-456-$it" } + pending.id,
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun paging_coldRestartLegacyUnknownUserRemainsConservativelyUnconfirmed() =
|
||||||
|
runTest {
|
||||||
|
val ambiguous =
|
||||||
|
ChatMessage(
|
||||||
|
id = "legacy-unknown-user",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "possibly unsent before migration",
|
||||||
|
)
|
||||||
|
fakeRepo.persistMessage(ambiguous, "session-456")
|
||||||
|
val cacheRead = CompletableDeferred<Unit>()
|
||||||
|
fakeRepo.dao.beforeRead = { cacheRead.await() }
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
coEvery {
|
||||||
|
ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
|
} returns pagingResponse(100..102)
|
||||||
|
viewModel.switchSession("session-456")
|
||||||
|
runCurrent()
|
||||||
|
cacheRead.complete(Unit)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(
|
||||||
|
(100..102).map { "rest-session-456-$it" } + ambiguous.id,
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.last()
|
||||||
|
.isHistoricalCache,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun paging_coldRestartPermanentLocalKeepsLocalOrdering() =
|
||||||
|
runTest {
|
||||||
|
val localCommand =
|
||||||
|
ChatMessage(
|
||||||
|
id = "local-command",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "/help",
|
||||||
|
)
|
||||||
|
fakeRepo.persistMessage(localCommand, "session-456")
|
||||||
|
val cacheRead = CompletableDeferred<Unit>()
|
||||||
|
fakeRepo.dao.beforeRead = { cacheRead.await() }
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
coEvery {
|
||||||
|
ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
|
} returns pagingResponse(100..102)
|
||||||
|
|
||||||
|
viewModel.switchSession("session-456")
|
||||||
|
runCurrent()
|
||||||
|
cacheRead.complete(Unit)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
(100..102).map { "rest-session-456-$it" } + localCommand.id,
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun paging_staleCacheDoesNotDemoteOptimisticIdentity() =
|
||||||
|
runTest {
|
||||||
|
val cachedIds = seedPagingCache(3)
|
||||||
|
val cacheRead = CompletableDeferred<Unit>()
|
||||||
|
fakeRepo.dao.beforeRead = { cacheRead.await() }
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
coEvery {
|
||||||
|
ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
|
} returns pagingResponse(100..102)
|
||||||
|
|
||||||
|
viewModel.switchSession("session-456")
|
||||||
|
runCurrent()
|
||||||
|
val attachment = Attachment("content://test/file", "file.txt", "text/plain")
|
||||||
|
val optimistic =
|
||||||
|
ChatMessage(
|
||||||
|
id = cachedIds[1],
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "Cached message 2",
|
||||||
|
attachments = listOf(attachment),
|
||||||
|
)
|
||||||
|
|
||||||
|
@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 = viewModel.uiState.value.messages + optimistic)
|
||||||
|
cacheRead.complete(Unit)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(cachedIds[0], cachedIds[2]) + (100..102).map { "rest-session-456-$it" } + cachedIds[1],
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.map { it.id },
|
||||||
|
)
|
||||||
|
val retained =
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.last()
|
||||||
|
assertFalse(retained.isHistoricalCache)
|
||||||
|
assertEquals(listOf(attachment), retained.attachments)
|
||||||
|
}
|
||||||
|
|
||||||
// Chat paging: deterministic regressions, with no real network or wall-clock timing.
|
// Chat paging: deterministic regressions, with no real network or wall-clock timing.
|
||||||
private fun seedPagingCache(
|
private fun seedPagingCache(
|
||||||
count: Int,
|
count: Int,
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ class FullBleedTurnsTest {
|
|||||||
|
|
||||||
private fun entries(vararg e: AgentEntry) = ChatTurn.Agent(e.toList())
|
private fun entries(vararg e: AgentEntry) = ChatTurn.Agent(e.toList())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun historicalRunningToolDoesNotClaimLiveActivity() {
|
||||||
|
val messages =
|
||||||
|
listOf(
|
||||||
|
msg("user", MessageRole.USER),
|
||||||
|
msg("old-tool", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING)
|
||||||
|
.copy(toolName = "terminal", isHistoricalCache = true),
|
||||||
|
)
|
||||||
|
assertEquals(AgentStatus.Typing, deriveAgentStatus(true, StreamingState(), messages))
|
||||||
|
assertEquals(
|
||||||
|
AgentStatus.Tool("terminal"),
|
||||||
|
deriveAgentStatus(true, StreamingState(), messages.map { it.copy(isHistoricalCache = false) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `empty list produces no turns`() {
|
fun `empty list produces no turns`() {
|
||||||
assertEquals(emptyList<ChatTurn>(), groupIntoTurns(emptyList()))
|
assertEquals(emptyList<ChatTurn>(), groupIntoTurns(emptyList()))
|
||||||
|
|||||||
Reference in New Issue
Block a user