fix(chat): keep historical cache out of the live transcript tail (#1253)
This commit is contained in:
1 parent
c581fd23d1
commit
94ec95a33c
18 files changed
+805
-18
No files matched your search
@@ -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
|
||||
.databaseBuilder(context, HermesDatabase::class.java, databaseName)
|
||||
.setDriver(driver())
|
||||
.addMigrations(HermesDatabase.MIGRATION_8_9)
|
||||
.addMigrations(HermesDatabase.MIGRATION_8_9, HermesDatabase.MIGRATION_9_10)
|
||||
.build()
|
||||
repository = ChatPersistenceRepository(database.chatMessageDao())
|
||||
}
|
||||
@@ -234,6 +234,29 @@ class ChatMessagePagingDeviceTest {
|
||||
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
|
||||
fun confirmedAliasPreservesLivePayloadAndCanonicalOrderAcrossReopen() =
|
||||
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() {
|
||||
database.close()
|
||||
driver().open(context.getDatabasePath(databaseName).absolutePath).use { connection ->
|
||||
|
||||
@@ -54,6 +54,8 @@ data class ChatMessageEntity(
|
||||
val sortGroup: Int = 1,
|
||||
@ColumnInfo(name = "sort_order", defaultValue = "0")
|
||||
val sortOrder: Long = 0,
|
||||
@ColumnInfo(name = "message_provenance", defaultValue = "'UNKNOWN'")
|
||||
val messageProvenance: String = "UNKNOWN",
|
||||
)
|
||||
|
||||
internal fun ChatMessageEntity.isSessionStartMarker(): Boolean =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
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.ToolStatus
|
||||
|
||||
@@ -40,6 +41,9 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
|
||||
completionId = completionId,
|
||||
restId = restId,
|
||||
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 && !isSessionStartMarker() },
|
||||
messageProvenance =
|
||||
MessageProvenance.entries.firstOrNull { it.name == messageProvenance }
|
||||
?: MessageProvenance.UNKNOWN,
|
||||
)
|
||||
|
||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
@@ -59,4 +63,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
tps = tps,
|
||||
completionId = completionId,
|
||||
restId = restId,
|
||||
messageProvenance = messageProvenance.name,
|
||||
)
|
||||
@@ -14,7 +14,7 @@ import java.io.File
|
||||
|
||||
@Database(
|
||||
entities = [ChatMessageEntity::class],
|
||||
version = 9,
|
||||
version = 10,
|
||||
exportSchema = true,
|
||||
)
|
||||
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 =
|
||||
withContext(Dispatchers.IO) {
|
||||
instance?.let { return@withContext it }
|
||||
@@ -152,6 +163,7 @@ abstract class HermesDatabase : RoomDatabase() {
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8,
|
||||
MIGRATION_8_9,
|
||||
MIGRATION_9_10,
|
||||
).fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
.also { instance = it }
|
||||
|
||||
@@ -35,6 +35,12 @@ data class ToolOutputRiskData(
|
||||
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(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val role: MessageRole,
|
||||
@@ -91,12 +97,20 @@ data class ChatMessage(
|
||||
val restId: String? = null,
|
||||
/** Cache insertion sequence for unconfirmed local rows; null before first persistence. */
|
||||
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. */
|
||||
internal val ChatMessage.canonicalRestId: String?
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -227,6 +227,17 @@ internal fun mapServerMessages(
|
||||
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
|
||||
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
|
||||
.mapIndexedNotNull { index, message ->
|
||||
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
|
||||
rich.copy(
|
||||
id = match.id,
|
||||
restId = message.canonicalRestId ?: match.canonicalRestId,
|
||||
restId = match.canonicalRestId ?: message.canonicalRestId,
|
||||
completionId = match.completionId ?: message.completionId,
|
||||
displayKind = message.displayKind ?: match.displayKind,
|
||||
displayKind = match.displayKind ?: message.displayKind,
|
||||
)
|
||||
}.toMap()
|
||||
val resolvedOrders =
|
||||
@@ -381,6 +403,7 @@ internal fun mergeTranscriptWithLive(
|
||||
match.copy(
|
||||
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
||||
displayKind = message.displayKind ?: match.displayKind,
|
||||
isHistoricalCache = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -398,11 +421,12 @@ internal fun mergeTranscriptWithLive(
|
||||
displayKind = message.displayKind ?: match.displayKind,
|
||||
tokenCount = message.tokenCount ?: match.tokenCount,
|
||||
completionId = message.completionId ?: match.completionId,
|
||||
isHistoricalCache = false,
|
||||
)
|
||||
}
|
||||
|
||||
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>,
|
||||
): List<ChatMessage> {
|
||||
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 hasPendingPredecessor = false
|
||||
var pendingLocalOrder: Long? = null
|
||||
@@ -451,6 +477,10 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
||||
?: latestCanonical
|
||||
}
|
||||
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 {
|
||||
hasPendingPredecessor = true
|
||||
pendingLocalOrder = message.localOrder
|
||||
|
||||
@@ -1832,6 +1832,7 @@ class ChatViewModel(
|
||||
content = text,
|
||||
attachments = if (attachments.isNotEmpty()) attachments else null,
|
||||
tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 },
|
||||
messageProvenance = MessageProvenance.LOCAL_PENDING,
|
||||
)
|
||||
|
||||
// Update UI immediately
|
||||
@@ -1894,6 +1895,7 @@ class ChatViewModel(
|
||||
content = text,
|
||||
attachments = if (attachments.isNotEmpty()) attachments else null,
|
||||
tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 },
|
||||
messageProvenance = MessageProvenance.LOCAL_PENDING,
|
||||
)
|
||||
|
||||
// Upload attachments then submit prompt
|
||||
@@ -3256,7 +3258,29 @@ class ChatViewModel(
|
||||
val snapshot = _uiState.value
|
||||
val computed =
|
||||
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 =
|
||||
if (cached) {
|
||||
mergeCachedTranscriptPage(page, snapshot.messages)
|
||||
|
||||
@@ -681,6 +681,7 @@ object ChatWsEventReducer {
|
||||
messages[toolIdx].copy(
|
||||
toolStatus = ToolStatus.COMPLETED,
|
||||
content = contentJson,
|
||||
isHistoricalCache = false,
|
||||
)
|
||||
messages[toolIdx] = updated
|
||||
|
||||
@@ -928,7 +929,11 @@ object ChatWsEventReducer {
|
||||
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
|
||||
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(
|
||||
state = state.copy(messages = messages),
|
||||
streamingState = streamingState,
|
||||
@@ -944,14 +949,14 @@ object ChatWsEventReducer {
|
||||
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
|
||||
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(
|
||||
state = state.copy(messages = messages),
|
||||
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(
|
||||
messages: List<ChatMessage>,
|
||||
name: String?,
|
||||
@@ -965,7 +970,8 @@ object ChatWsEventReducer {
|
||||
it.toolCallId == toolId &&
|
||||
it.toolStatus == status
|
||||
}
|
||||
if (byId >= 0) return byId
|
||||
// An explicit identity must never claim a different historical invocation.
|
||||
return byId
|
||||
}
|
||||
|
||||
return messages.indexOfLast {
|
||||
|
||||
@@ -143,8 +143,8 @@ internal fun ToolBubble(
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
|
||||
val view =
|
||||
remember(message.content, message.toolName, message.toolStatus) {
|
||||
parseToolOutput(message.content, message.toolName, message.toolStatus == ToolStatus.RUNNING)
|
||||
remember(message.content, message.toolName, message.isToolRunning) {
|
||||
parseToolOutput(message.content, message.toolName, message.isToolRunning)
|
||||
}
|
||||
val config = ToolSchemaRegistry.getDisplayConfig(message.toolName)
|
||||
|
||||
@@ -182,7 +182,7 @@ internal fun ToolBubble(
|
||||
HeaderRow(message, config, contentColor, statusColors)
|
||||
|
||||
// ── Tool progress preview (tool.progress) ──
|
||||
if (message.toolStatus == ToolStatus.RUNNING && !message.progressPreview.isNullOrEmpty()) {
|
||||
if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) {
|
||||
Text(
|
||||
text = message.progressPreview,
|
||||
style =
|
||||
@@ -534,7 +534,7 @@ private fun HeaderRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Status icon or spinner
|
||||
if (message.toolStatus == ToolStatus.RUNNING) {
|
||||
if (message.isToolRunning) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.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.StreamingState
|
||||
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.isToolRunning
|
||||
import com.m57.hermescontrol.ui.chat.isUserTurnBoundary
|
||||
|
||||
/** Live status for an agent turn before visible assistant prose is available. */
|
||||
@@ -61,7 +61,7 @@ internal fun deriveAgentStatus(
|
||||
.asReversed()
|
||||
.firstOrNull {
|
||||
it.role == MessageRole.TOOL &&
|
||||
it.toolStatus == ToolStatus.RUNNING
|
||||
it.isToolRunning
|
||||
}
|
||||
val hasVisibleStreamingContent = streamingState.streamingMessage?.content?.isNotBlank() == true
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
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.ToolStatus
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -187,6 +188,32 @@ class ChatMessageMapperTest {
|
||||
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
|
||||
fun roundTripWithoutDisplayKindStaysNull() {
|
||||
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
|
||||
|
||||
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
|
||||
fun confirmedLiveOccurrenceCannotConsumeEarlierIdenticalPage() {
|
||||
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.
|
||||
private fun seedPagingCache(
|
||||
count: Int,
|
||||
|
||||
@@ -26,6 +26,21 @@ class FullBleedTurnsTest {
|
||||
|
||||
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
|
||||
fun `empty list produces no turns`() {
|
||||
assertEquals(emptyList<ChatTurn>(), groupIntoTurns(emptyList()))
|
||||
|
||||
Reference in new issue
Block a user