From c581fd23d1731bf714873f5b06f11793db0c75da Mon Sep 17 00:00:00 2001
From: Lx-204 <15377891490@163.com>
Date: Thu, 24 Sep 2026 16:27:12 +0800
Subject: [PATCH 001/107] feat(i18n): complete Simplified Chinese translation
(#1252)
---
app/src/main/res/values-zh/strings.xml | 100 +++++++++++++++++++++++++
1 file changed, 100 insertions(+)
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index 01a08977..71b28b63 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -1509,4 +1509,104 @@
%1$s为必填项
无法发送设置响应,请重试。
正在等待 Hermes…
+ 时间线
+ 回到最新
+ 此窗口之前和之后还有更多消息。
+ 此窗口之后还有更新的消息。
+ 此窗口之前还有更早的消息。
+ 正在查看更早的消息
+ 推理选项
+ 还没有提示词。
+ 加载更多
+ 更早的提示词
+ 刷新
+ 重试
+ 不用加载完整对话,就能跳到某条提示词。
+ 对话时间线
+ 本地网络访问被拒绝。请在系统设置中授予“本地网络”权限,或改用远程网关地址连接。
+ 能力
+ 提交 SHA
+ 文档
+ 维护者
+ 仓库
+ 需要 Hermes
+ 层级
+ 停止生成
+ %1$s 本轮未发言
+ 用户停止了讨论
+ 无输出
+ 退出码 0(无输出)
+ 运行中…
+ 完成 %1$d 项任务
+ Hermes 需要你的输入
+ 没有匹配筛选条件或搜索词的插件
+ 未找到目录插件
+ 全部
+ 社区
+ 官方
+ 已安装
+ 插件“%1$s”安装成功
+ 正在安装 %1$s…
+ 作者 %1$s
+ 搜索目录插件…
+ 社区
+ 官方
+ 更新
+ 目录
+ 已安装
+ 由定时自动化启动的运行会显示在这里。
+ 暂无自动化历史
+ 任务 %1$s
+ 已加载 %1$d 次运行
+ 自动运行
+ 已加载消息
+ 自动化
+ 对话
+ 凭据保险库
+ 密码管理器与凭据
+ 隐藏转录
+ 查看转录
+ 子代理
+ 暂无转录输出。
+ 无法加载转录
+ 正在加载实时转录…
+ 重试
+ 滚动显示最近 16KB(更早输出已截断)
+ 下载并安装
+ 稍后提醒
+ 跳过此版本
+ 更新内容
+ 更新到 %1$s
+ 提交
+ 输入 %1$s 的两步验证或校验码。
+ 跳过
+ 验证码
+ 验证码
+ 保险库中暂无条目
+ 已存条目
+ 锁定全部管理器
+ 已锁定全部密码管理器
+ 保存并填充
+ 将 %1$s 的账号密码保存到凭据保险库?
+ 不保存
+ 用户名或邮箱
+ 密码
+ 保存登录信息到保险库
+ 已安装
+ 锁定
+ 已锁定
+ 未安装
+ 解锁
+ 已解锁
+ 外部密码管理器为智能体提供自动填充凭据。
+ 密码管理器
+ 解锁
+ 智能体需要凭据才能继续。输入主密码以解锁 %1$s。
+ 解锁
+ 解锁 %1$s
+ 保持锁定
+ 无法解锁 %1$s
+ 主密码
+ %1$s 解锁成功
+ 解锁 %1$s
From 94ec95a33cdeb9432ad8dd9351c1eefcfbed219b Mon Sep 17 00:00:00 2001
From: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
Date: Thu, 24 Sep 2026 20:27:39 +0200
Subject: [PATCH 002/107] fix(chat): keep historical cache out of the live
transcript tail (#1253)
---
.../10.json | 143 +++++++++++++
.../data/local/ChatMessagePagingDeviceTest.kt | 82 +++++++-
.../data/local/ChatMessageEntity.kt | 2 +
.../data/local/ChatMessageMapper.kt | 5 +
.../data/local/HermesDatabase.kt | 14 +-
.../m57/hermescontrol/ui/chat/ChatMessage.kt | 14 ++
.../ui/chat/ChatServerMessageMapper.kt | 13 +-
.../ui/chat/ChatTranscriptMerge.kt | 38 +++-
.../hermescontrol/ui/chat/ChatViewModel.kt | 26 ++-
.../ui/chat/ChatWsEventReducer.kt | 14 +-
.../m57/hermescontrol/ui/chat/ToolBubble.kt | 8 +-
.../ui/chat/fullbleed/AgentStatusIndicator.kt | 4 +-
.../data/local/ChatMessageMapperTest.kt | 27 +++
.../local/HermesDatabaseMigrationsTest.kt | 12 ++
.../ui/chat/ChatCachedHistoryTest.kt | 155 ++++++++++++++
.../ui/chat/ChatPagingMergeTest.kt | 57 +++++
.../ui/chat/ChatViewModelTest.kt | 194 ++++++++++++++++++
.../ui/chat/FullBleedTurnsTest.kt | 15 ++
18 files changed, 805 insertions(+), 18 deletions(-)
create mode 100644 app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/10.json
create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCachedHistoryTest.kt
diff --git a/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/10.json b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/10.json
new file mode 100644
index 00000000..8c91d72b
--- /dev/null
+++ b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/10.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/data/local/ChatMessagePagingDeviceTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/data/local/ChatMessagePagingDeviceTest.kt
index 795859bb..2340f16f 100644
--- a/app/src/androidTest/java/com/m57/hermescontrol/data/local/ChatMessagePagingDeviceTest.kt
+++ b/app/src/androidTest/java/com/m57/hermescontrol/data/local/ChatMessagePagingDeviceTest.kt
@@ -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) {
+ 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 ->
diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt
index 0695140d..9b645e7a 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt
@@ -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 =
diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt
index 97c39b5b..5be0dd2d 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt
@@ -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,
)
diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt
index c30d2891..6464a569 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt
@@ -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 }
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt
index f65c6a6d..34d6f29b 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt
@@ -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.
*/
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt
index 59327ce1..394dc888 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt
@@ -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
+ }
}
}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt
index cede6a9c..1eb48730 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt
@@ -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.inTranscriptOrder(
resolvedOrders: Map,
): List {
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.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
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
index 8c8ad8a5..f1a32bfe 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
@@ -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)
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt
index 4f9088cd..7260e860 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt
@@ -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,
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 {
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt
index 54a8b4d7..3eb0c605 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt
@@ -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,
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt
index 1f6e1cc2..f9460c6a 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt
@@ -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
diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt
index 5ce02d57..5c9dcc21 100644
--- a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt
@@ -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 =
diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt
index f57f20ac..c4a9e72e 100644
--- a/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt
@@ -97,4 +97,16 @@ class HermesDatabaseMigrationsTest {
)
}
}
+
+ @Test
+ fun migration9to10_addsDurableProvenanceWithoutInventingDeliveryState() =
+ runBlocking {
+ val connection = mockk(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'",
+ )
+ }
+ }
}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCachedHistoryTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCachedHistoryTest.kt
new file mode 100644
index 00000000..6d7ba108
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCachedHistoryTest.kt
@@ -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))
+ }
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt
index def65f96..6a3297a9 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt
@@ -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)) {
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
index 3e77cf3e..0bdb03a7 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
@@ -5389,6 +5389,200 @@ class ChatViewModelTest {
)
}
+ @Test
+ fun paging_staleCacheBeforeRestDoesNotBecomeTheLatestTail() =
+ runTest {
+ val cachedIds = seedPagingCache(3)
+ val response = CompletableDeferred>()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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()
+ 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
+ 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,
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt
index aff84307..1b2ae77b 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt
@@ -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(), groupIntoTurns(emptyList()))
From 0d4c0b40ad13dbe174d430a0e235c4cacc09bb19 Mon Sep 17 00:00:00 2001
From: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
Date: Thu, 24 Sep 2026 20:37:04 +0200
Subject: [PATCH 003/107] feat(chat): show session-scoped reply failure details
(#1255)
---
.../ui/chat/components/ReplyErrorCardTest.kt | 139 ++++++++
.../notification/ChatNotificationService.kt | 69 +++-
.../m57/hermescontrol/ui/chat/ChatScreen.kt | 38 +++
.../hermescontrol/ui/chat/ChatViewModel.kt | 71 +++-
.../ui/chat/ChatWsEventReducer.kt | 138 +++++++-
.../m57/hermescontrol/ui/chat/ReplyFailure.kt | 59 ++++
.../ui/chat/components/ReplyErrorCard.kt | 122 +++++++
.../ui/chat/fullbleed/FullBleedChatList.kt | 4 +-
app/src/main/res/values/reply_errors.xml | 16 +
.../ChatNotificationServiceTest.kt | 59 ++++
.../ui/chat/ChatReplyErrorTest.kt | 315 ++++++++++++++++++
.../ui/chat/ChatViewModelTest.kt | 188 +++++++++++
.../ui/chat/ReplyFailureDetailsTest.kt | 61 ++++
13 files changed, 1251 insertions(+), 28 deletions(-)
create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReplyErrorCardTest.kt
create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ReplyFailure.kt
create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReplyErrorCard.kt
create mode 100644 app/src/main/res/values/reply_errors.xml
create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatReplyErrorTest.kt
create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ReplyFailureDetailsTest.kt
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReplyErrorCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReplyErrorCardTest.kt
new file mode 100644
index 00000000..dfe453f5
--- /dev/null
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReplyErrorCardTest.kt
@@ -0,0 +1,139 @@
+package com.m57.hermescontrol.ui.chat.components
+
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.width
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.m57.hermescontrol.R
+import com.m57.hermescontrol.theme.HermesControlTheme
+import com.m57.hermescontrol.ui.chat.ReplyFailure
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ReplyErrorCardTest {
+ @get:Rule
+ val compose = createAndroidComposeRule()
+
+ @Test
+ fun detailsStartCollapsedAndCopyIsScrubbedWithoutAutomaticSharing() {
+ var copied = ""
+ var shared = false
+ compose.setContent {
+ HermesControlTheme {
+ Box(Modifier.width(320.dp)) {
+ ReplyErrorCard(
+ failure = ReplyFailure("HTTP 429\nAuthorization: Bearer private-key"),
+ onDismiss = {},
+ onOpenLogs = {},
+ onCopy = { copied = it },
+ onShare = { shared = true },
+ )
+ }
+ }
+ }
+ compose.onNodeWithText("HTTP 429", substring = true).assertDoesNotExist()
+
+ compose.onNodeWithText(compose.activity.getString(R.string.chat_reply_failed_details)).performClick()
+ compose.onNodeWithText("HTTP 429", substring = true).assertIsDisplayed()
+ compose.onNodeWithText("private-key", substring = true).assertDoesNotExist()
+ compose.onNodeWithText(compose.activity.getString(R.string.chat_reply_failed_copy)).performClick()
+ compose.runOnIdle {
+ assertEquals("HTTP 429\nAuthorization: [REDACTED]", copied)
+ assertFalse(shared)
+ }
+ }
+
+ @Test
+ fun sharingRequiresExpandedReviewAndUsesExactSanitizedPayload() {
+ var shared = ""
+ var logsOpened = 0
+ var dismissed = 0
+ compose.setContent {
+ HermesControlTheme {
+ ReplyErrorCard(
+ failure = ReplyFailure("Provider failed\nAuthorization: Bearer private-token"),
+ onDismiss = { dismissed++ },
+ onOpenLogs = { logsOpened++ },
+ onCopy = {},
+ onShare = { shared = it },
+ )
+ }
+ }
+ compose.runOnIdle {
+ assertEquals("", shared)
+ assertEquals(0, logsOpened)
+ assertEquals(0, dismissed)
+ }
+ val detailsLabel = compose.activity.getString(R.string.chat_reply_failed_details)
+ val collapsedLabel = compose.activity.getString(R.string.chat_reply_failed_details_collapsed)
+ val expandedLabel = compose.activity.getString(R.string.chat_reply_failed_details_expanded)
+ val shareLabel = compose.activity.getString(R.string.chat_reply_failed_share)
+
+ compose
+ .onNodeWithText(detailsLabel)
+ .assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, collapsedLabel))
+ compose.onNodeWithText(shareLabel).assertIsNotEnabled().performClick()
+ compose.runOnIdle { assertEquals("", shared) }
+
+ compose.onNodeWithText(detailsLabel).performClick()
+ compose
+ .onNodeWithText(detailsLabel)
+ .assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, expandedLabel))
+ compose.onNodeWithText(shareLabel).assertIsEnabled().performClick()
+ compose.onNodeWithText(compose.activity.getString(R.string.chat_reply_failed_logs)).performClick()
+ compose
+ .onNodeWithContentDescription(
+ compose.activity.getString(R.string.chat_reply_failed_dismiss),
+ ).performClick()
+ compose.runOnIdle {
+ assertEquals("Provider failed\nAuthorization: [REDACTED]", shared)
+ assertEquals(1, logsOpened)
+ assertEquals(1, dismissed)
+ }
+ }
+
+ @Test
+ fun expandedReviewResetsForANewFailure() {
+ val failure = androidx.compose.runtime.mutableStateOf(ReplyFailure("First failure"))
+ compose.setContent {
+ HermesControlTheme {
+ ReplyErrorCard(
+ failure = failure.value,
+ onDismiss = {},
+ onOpenLogs = {},
+ onCopy = {},
+ onShare = {},
+ )
+ }
+ }
+
+ val detailsLabel = compose.activity.getString(R.string.chat_reply_failed_details)
+ val collapsedLabel = compose.activity.getString(R.string.chat_reply_failed_details_collapsed)
+ val shareLabel = compose.activity.getString(R.string.chat_reply_failed_share)
+ compose.onNodeWithText(detailsLabel).performClick()
+ compose.onNodeWithText(shareLabel).assertIsEnabled()
+
+ compose.runOnIdle { failure.value = ReplyFailure("Second failure") }
+
+ compose
+ .onNodeWithText(detailsLabel)
+ .assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, collapsedLabel))
+ compose.onNodeWithText(shareLabel).assertIsNotEnabled()
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt
index 831aa535..48342326 100644
--- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt
+++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt
@@ -17,6 +17,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
+import com.m57.hermescontrol.ui.chat.replyFailureFromPayload
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -27,6 +28,46 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
+internal data class MessageCompleteNotificationPlan(
+ val text: String,
+ val sessionId: String?,
+ val isReplyMessage: Boolean,
+ val completionId: String?,
+ val correlationText: String?,
+ val allowInlineReply: Boolean,
+)
+
+internal fun messageCompleteNotificationPlan(
+ event: WsEvent.MessageComplete,
+ targetSessionId: String?,
+ newMessageText: String,
+ failureText: String,
+): MessageCompleteNotificationPlan {
+ if (replyFailureFromPayload(event.rawPayload, fallback = "") != null) {
+ return MessageCompleteNotificationPlan(
+ text = failureText,
+ sessionId = targetSessionId,
+ isReplyMessage = false,
+ completionId = null,
+ correlationText = null,
+ allowInlineReply = false,
+ )
+ }
+
+ return MessageCompleteNotificationPlan(
+ text =
+ event.text
+ .take(100)
+ .replace("\n", " ")
+ .ifBlank { newMessageText },
+ sessionId = targetSessionId,
+ isReplyMessage = true,
+ completionId = event.completionId,
+ correlationText = event.text,
+ allowInlineReply = true,
+ )
+}
+
/**
* Foreground service that keeps the WebSocket connection alive while the app
* is backgrounded and posts a notification when a new assistant reply
@@ -111,19 +152,22 @@ class ChatNotificationService : Service() {
if (!isAppInForeground.get()) {
when (event) {
is WsEvent.MessageComplete -> {
- val preview =
- event.text
- .take(100)
- .replace("\n", " ")
- .ifBlank { getString(R.string.notif_new_message) }
val targetSessionId =
event.storedSessionId
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
+ val plan =
+ messageCompleteNotificationPlan(
+ event = event,
+ targetSessionId = targetSessionId,
+ newMessageText = getString(R.string.notif_new_message),
+ failureText = getString(R.string.chat_reply_failed_title),
+ )
showReplyNotification(
- text = preview,
- sessionId = targetSessionId,
- isReplyMessage = true,
- completionId = event.completionId,
+ text = plan.text,
+ sessionId = plan.sessionId,
+ isReplyMessage = plan.isReplyMessage,
+ completionId = plan.completionId,
+ allowInlineReply = plan.allowInlineReply,
// The durable REST row for this turn, when the
// boundary armed before the prompt was submitted
// still lets us name it unambiguously. Null is a
@@ -132,7 +176,9 @@ class ChatNotificationService : Service() {
// is strictly better than dismissing the wrong
// duplicate reply.
serverMessageId =
- coalesceTurnRow(targetSessionId, event.text),
+ plan.correlationText?.let {
+ coalesceTurnRow(plan.sessionId, it)
+ },
)
// The wait is over — retire the foreground
// service. The reply notification above
@@ -216,6 +262,7 @@ class ChatNotificationService : Service() {
isReplyMessage: Boolean = false,
completionId: String? = null,
serverMessageId: Int? = null,
+ allowInlineReply: Boolean = true,
) {
val builder =
NotificationCompat
@@ -262,7 +309,7 @@ class ChatNotificationService : Service() {
)
}
- if (!sessionId.isNullOrBlank()) {
+ if (allowInlineReply && !sessionId.isNullOrBlank()) {
val replyLabel = getString(R.string.notif_reply_placeholder)
val remoteInput =
RemoteInput
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt
index 65e30861..78850553 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt
@@ -64,9 +64,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
@@ -76,8 +78,10 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
+import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.HistoryScreen
+import com.m57.hermescontrol.LogsScreen
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
@@ -102,6 +106,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
+import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
@@ -784,6 +789,39 @@ fun ChatScreen(
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
openingAttachmentPath = state.openingAttachmentPath,
onImageClick = { viewingImage = it },
+ replyErrorContent =
+ state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure ->
+ {
+ val clipboard = LocalClipboardManager.current
+ val copiedMessage = stringResource(R.string.chat_reply_failed_copied)
+ val shareTitle = stringResource(R.string.chat_reply_failed_share)
+ val shareUnavailable = stringResource(R.string.chat_reply_failed_share_unavailable)
+ ReplyErrorCard(
+ failure = failure,
+ onDismiss = { viewModel.dismissReplyFailure(failure.id) },
+ onOpenLogs = { NavigationController.navigateTo(LogsScreen) },
+ onCopy = { details ->
+ clipboard.setText(AnnotatedString(details))
+ scrollScope.launch { snackbarHostState.showSnackbar(copiedMessage) }
+ },
+ onShare = { details ->
+ val report = "Hermes Mobile ${BuildConfig.VERSION_NAME}\n\n$details"
+ val intent =
+ Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, report)
+ }
+ try {
+ launchExternalActivity {
+ context.startActivity(Intent.createChooser(intent, shareTitle))
+ }
+ } catch (_: ActivityNotFoundException) {
+ scrollScope.launch { snackbarHostState.showSnackbar(shareUnavailable) }
+ }
+ },
+ )
+ }
+ },
)
// Loading overlay
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
index f1a32bfe..dfdabd63 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
@@ -137,6 +137,10 @@ data class ChatUiState(
/** Standalone streaming message — rendered after the main list. */
val streamingMessage: ChatMessage? = null,
val errorMessage: String? = null,
+ /** Persistent until dismissed/new turn; distinct from one-shot RPC/network snackbars. */
+ val replyFailure: ReplyFailure? = null,
+ /** Identity of the single non-durable failed assistant projection for this session lifecycle. */
+ val replyFailureProjection: ReplyFailureProjection? = null,
// Background job completion toast (issue #527) — non-blocking snackbar
val backgroundCompleteMessage: String? = null,
// Attachment feedback — surfaced as a non-blocking snackbar (issue #724)
@@ -842,6 +846,23 @@ class ChatViewModel(
}
private fun handleWsEvent(event: WsEvent) {
+ // Filter explicitly scoped terminal/start events before buffer resets and reduction.
+ // Legacy unscoped failures are ambiguous across sessions, so fail closed: gateways
+ // must include session_id for failed replies to be surfaced by this client.
+ val turnSessionId =
+ when (event) {
+ is WsEvent.MessageStart -> event.sessionId
+ is WsEvent.MessageComplete -> event.sessionId
+ is WsEvent.MessageDone -> event.sessionId
+ else -> null
+ }
+ if (turnSessionId != null && !isCurrentSession(turnSessionId)) return
+ if (event is WsEvent.MessageComplete && event.rawPayload?.get("status") == "error" &&
+ event.sessionId == null
+ ) {
+ return
+ }
+
// RpcError is reduced before ViewModel request handling. Drop stale
// session errors here so the shared reducer cannot clear loading or
// surface an error for a newly selected session.
@@ -1238,10 +1259,13 @@ class ChatViewModel(
// ── Message streaming ────────────────────────────────────────────────
- /**
- * Checks if an incoming WS event belongs to the currently active
- * session. Returns true if the event should be processed.
- */
+ fun dismissReplyFailure(id: String) {
+ _uiState.update { state ->
+ if (state.replyFailure?.id == id) state.copy(replyFailure = null) else state
+ }
+ }
+
+ /** Checks if an incoming WS event belongs to the currently active session. */
private fun isCurrentSession(eventSessionId: String?): Boolean {
// If the event has no session ID, process it (legacy compatibility)
if (eventSessionId == null) return true
@@ -1510,20 +1534,23 @@ class ChatViewModel(
WsMethods.SESSION_RESUME -> {
val resultMap = result as? Map
+ // A resume response may carry retained terminal state. Correlate the
+ // exact request and stored session before binding the runtime id or
+ // projecting any failure; a late response must not touch the new chat.
+ val resumeRequest = request ?: return
+ val sessionId = resumeRequest.sessionId ?: return
+ if (sessionId != _uiState.value.currentSessionId) return
+ val resumedStoredId = (resultMap?.get("resumed") as? String)?.takeIf { it.isNotBlank() }
+ if (resumedStoredId != null && resumedStoredId != sessionId) return
val runtimeId = (resultMap?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
if (runtimeId == null) {
- val sessionId = request?.sessionId ?: _uiState.value.currentSessionId ?: return
- handleResumeFailure(sessionId, sessionGeneration, "Invalid session resume response")
+ handleResumeFailure(sessionId, resumeRequest.generation, "Invalid session resume response")
return
}
runtimeSessionId = runtimeId
connectionOperationDelegate.bindSession(runtimeId)
// Resume succeeded — the gateway confirmed the DB row.
sessionHasServerPresence = true
- val sessionId =
- request?.sessionId
- ?: (resultMap["resumed"] as? String)
- ?: _uiState.value.currentSessionId
// Parse session info from backend — model, provider, reasoning_effort
val infoMap = resultMap["info"] as? Map
@@ -1592,7 +1619,8 @@ class ChatViewModel(
ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId)
addSystemMessage("Session resumed")
fetchContextUsage()
- val generation = request?.generation ?: sessionGeneration
+ projectRetainedReplyFailure(resultMap["inflight"] as? Map, runtimeId)
+ val generation = resumeRequest.generation
resumedGeneration = generation
finishResumeWhenHydrated(generation)
subagentsDelegate.hydrateSubagents(runtimeSessionId ?: sessionId)
@@ -1663,6 +1691,25 @@ class ChatViewModel(
}
}
+ /**
+ * Replays a gateway-retained failed turn through the same reducer path as a
+ * live terminal `message.complete(status=error)`. The assistant field is
+ * the partial display projection; user/transcript fields are never exported
+ * as diagnostics, and reducer effects intentionally do not persist it.
+ */
+ private fun projectRetainedReplyFailure(
+ inflight: Map?,
+ runtimeId: String,
+ ) {
+ if (inflight?.get("status") != "error") return
+ val result =
+ ChatWsEventReducer.reduceRetainedReplyFailure(_uiState.value, inflight, runtimeId)
+ _uiState.value = result.state
+ _streamingState.value = result.streamingState
+ dispatchReducerEffects(result.effects)
+ streamingController.resetStreaming()
+ }
+
@Suppress("UNCHECKED_CAST")
private fun handleDispatchResult(result: Any?) {
val map = result as? Map<*, *> ?: return
@@ -3546,6 +3593,8 @@ class ChatViewModel(
hasOlderMessages = false,
streamingMessage = null,
errorMessage = null,
+ replyFailure = null,
+ replyFailureProjection = null,
openError = null,
clarifyRequest = null,
sudoPrompt = null,
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt
index 7260e860..28d248be 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt
@@ -69,6 +69,40 @@ private fun stripSealedOrphanPrefix(
* Android or ViewModel classes. Easy to unit test.
*/
object ChatWsEventReducer {
+ /**
+ * Projects retained terminal state without consulting any live streaming buffers.
+ * Only `inflight.assistant` may become assistant prose; stale UI text, reasoning,
+ * and message ids are deliberately excluded.
+ */
+ fun reduceRetainedReplyFailure(
+ state: ChatUiState,
+ inflight: Map,
+ currentSessionId: String,
+ ): ReducerResult {
+ if (inflight["status"] != "error") return ReducerResult(state, StreamingState())
+ val assistant = (inflight["assistant"] as? String).orEmpty()
+ val payload = inflight.toMutableMap().apply { put("partial", assistant.isNotBlank()) }
+ val cleanState =
+ state.copy(
+ isAgentTyping = false,
+ isThinking = false,
+ thinkingText = "",
+ streamingMessage = null,
+ )
+ return reduce(
+ cleanState,
+ StreamingState(),
+ WsEvent.MessageComplete(
+ text = assistant,
+ sessionId = currentSessionId,
+ // Retained inflight has no completion identity; suppress the event's random default.
+ completionId = "",
+ rawPayload = payload,
+ ),
+ currentSessionId,
+ )
+ }
+
fun reduce(
state: ChatUiState,
streamingState: StreamingState,
@@ -239,6 +273,18 @@ object ChatWsEventReducer {
streamingState: StreamingState,
event: WsEvent.MessageStart,
): ReducerResult {
+ val previousFailureMessageId = state.replyFailureProjection?.messageId
+ val turnState =
+ state.copy(
+ messages =
+ if (previousFailureMessageId == null) {
+ state.messages
+ } else {
+ state.messages.filterNot { it.id == previousFailureMessageId }
+ },
+ replyFailure = null,
+ replyFailureProjection = null,
+ )
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
@@ -252,10 +298,10 @@ object ChatWsEventReducer {
val effects = mutableListOf()
val cleanedSubagents =
- if (state.subagentIndicators.all { it.isComplete || it.isFailed }) {
+ if (turnState.subagentIndicators.all { it.isComplete || it.isFailed }) {
emptyList()
} else {
- state.subagentIndicators
+ turnState.subagentIndicators
}
// Build new state: finalize any orphan streaming message, then set the new one
@@ -270,13 +316,13 @@ object ChatWsEventReducer {
tps = null,
)
orphan = finalized
- state.copy(
- messages = state.messages.upsertById(finalized),
+ turnState.copy(
+ messages = turnState.messages.upsertById(finalized),
isAgentTyping = true,
subagentIndicators = cleanedSubagents,
)
} else {
- state.copy(
+ turnState.copy(
isAgentTyping = true,
subagentIndicators = cleanedSubagents,
)
@@ -406,6 +452,8 @@ object ChatWsEventReducer {
streamingState: StreamingState,
event: WsEvent.MessageComplete,
): ReducerResult {
+ val failure = replyFailureFromPayload(event.rawPayload, event.text)
+ if (failure != null) return onReplyFailure(state, streamingState, event, failure)
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
@@ -520,6 +568,86 @@ object ChatWsEventReducer {
)
}
+ // Keep terminal diagnostics out of normal assistant messages and persistence.
+ private fun onReplyFailure(
+ state: ChatUiState,
+ streamingState: StreamingState,
+ event: WsEvent.MessageComplete,
+ failure: ReplyFailure,
+ ): ReducerResult {
+ val usageState = event.rawPayload?.let(::parseUsageSnapshot)?.let { applyUsageSnapshot(state, it) } ?: state
+ val streaming = streamingState.streamingMessage
+ val text =
+ if (event.rawPayload?.get("partial") == true) {
+ event.text
+ .takeIf { it.isNotBlank() }
+ ?.let { stripSealedOrphanPrefix(it, state.messages, streamingState.sealedOrphanIds) }
+ ?: streaming?.content.orEmpty()
+ } else {
+ streaming?.content.orEmpty()
+ }
+ val reasoning = streamingState.reasoningText.ifBlank { streaming?.reasoningText.orEmpty() }
+ val existingProjection = state.replyFailureProjection
+ val completionId = event.completionId?.takeIf { it.isNotBlank() } ?: existingProjection?.completionId
+ val sessionIdentity = state.currentSessionId ?: event.sessionId.orEmpty()
+ val failureId =
+ existingProjection?.failureId
+ ?: event.completionId?.takeIf { it.isNotBlank() }?.let { "reply-failure-$sessionIdentity-$it" }
+ ?: failure.id
+ val projectionMessageId =
+ existingProjection?.messageId
+ ?: streaming?.id
+ ?: event.completionId?.takeIf { it.isNotBlank() }?.let { "reply-failure-message-$it" }
+ ?: if (text.isNotBlank() || reasoning.isNotBlank()) "reply-failure-message-$failureId" else null
+ val projection = ReplyFailureProjection(projectionMessageId, failureId, completionId)
+ val existingProjectedMessage =
+ projectionMessageId?.let { id -> usageState.messages.firstOrNull { it.id == id } }
+ val partial =
+ if (text.isNotBlank() || reasoning.isNotBlank()) {
+ val base =
+ streaming
+ ?: existingProjectedMessage
+ ?: ChatMessage(
+ id = checkNotNull(projectionMessageId),
+ role = MessageRole.ASSISTANT,
+ content = "",
+ )
+ base.copy(
+ id = checkNotNull(projectionMessageId),
+ content = text,
+ reasoningText = reasoning,
+ isStreaming = false,
+ finishTimestamp = System.currentTimeMillis(),
+ completionId = completionId,
+ )
+ } else {
+ null
+ }
+ val messagesWithoutOldProjection =
+ existingProjection?.messageId?.let { oldId -> usageState.messages.filterNot { it.id == oldId } }
+ ?: usageState.messages
+ val projectedMessages =
+ partial?.let { messagesWithoutOldProjection.upsertById(it) } ?: messagesWithoutOldProjection
+ // A failed partial is a transient projection of gateway `inflight`, not a
+ // successful transcript row. Persisting it would erase failure provenance on
+ // hydration and make a later session reopen look healthy.
+ val effects = listOf(ReducerEffect.RefreshSessions, ReducerEffect.RefreshContextUsage)
+ return ReducerResult(
+ state =
+ usageState.copy(
+ messages = projectedMessages,
+ replyFailure = failure.copy(id = failureId),
+ replyFailureProjection = projection,
+ isAgentTyping = false,
+ isThinking = false,
+ streamingMessage = null,
+ clarifyRequest = null,
+ ),
+ streamingState = StreamingState(),
+ effects = effects,
+ )
+ }
+
// ── MessageDone ───────────────────────────────────────────────────
private fun onMessageDone(
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ReplyFailure.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ReplyFailure.kt
new file mode 100644
index 00000000..cca7527c
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ReplyFailure.kt
@@ -0,0 +1,59 @@
+package com.m57.hermescontrol.ui.chat
+
+import java.util.UUID
+
+/** Session-scoped presentation, never a send receipt or a persisted transcript row. */
+data class ReplyFailure(
+ val details: String,
+ val id: String = UUID.randomUUID().toString(),
+)
+
+/** Stable identity for the one transient failed-reply projection in a session lifecycle. */
+data class ReplyFailureProjection(
+ val messageId: String?,
+ val failureId: String,
+ val completionId: String? = null,
+)
+
+/** Only a terminal turn failure is a failed reply; RPC/transport errors are separate. */
+internal fun replyFailureFromPayload(
+ payload: Map?,
+ fallback: String,
+): ReplyFailure? {
+ if (payload?.get("status") != "error") return null
+ val surface = payload["error_surface"] as? Map<*, *>
+ val details =
+ buildList {
+ for (key in listOf("layer", "code", "provider", "model")) {
+ (surface?.get(key) as? String)?.takeIf { it.isNotBlank() }?.let { add("$key: $it") }
+ }
+ val error = (payload["error"] as? String)?.takeIf { it.isNotBlank() }
+ add(error ?: if (payload["partial"] == true) "Unknown gateway error" else fallback)
+ }.joinToString("\n")
+ return ReplyFailure(sanitizeReplyErrorDetails(details))
+}
+
+/** Best-effort scrubbing before display/copy/share; never includes a transcript or request payload. */
+internal fun sanitizeReplyErrorDetails(raw: String?): String {
+ if (raw.isNullOrBlank()) return "Unknown gateway error"
+ var safe = raw.take(8_192)
+ safe = PRIVATE_KEY.replace(safe, "[REDACTED]")
+ safe = HEADER.replace(safe) { "${it.groupValues[1]}[REDACTED]" }
+ safe = URL.replace(safe, "[REDACTED URL]")
+ safe = SECRET_ASSIGNMENT.replace(safe) { "${it.groupValues[1]}[REDACTED]" }
+ safe = BEARER.replace(safe, "Bearer [REDACTED]")
+ safe = PROVIDER_KEY.replace(safe, "[REDACTED]")
+ return safe.take(8_192)
+}
+
+private val PRIVATE_KEY = Regex("-----BEGIN [^-]*PRIVATE KEY-----[\\s\\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)")
+private val HEADER = Regex("(?im)^([ \\t]*(?:authorization|proxy-authorization|set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*")
+private val URL = Regex("(?i)(?:https?|wss?)://[^\\s<>\\\"']+")
+private val SECRET_ASSIGNMENT =
+ Regex(
+ "(?i)((? Unit,
+ onOpenLogs: () -> Unit,
+ onCopy: (String) -> Unit,
+ onShare: (String) -> Unit,
+) {
+ var expanded by rememberSaveable(failure.id) { mutableStateOf(false) }
+
+ val details = remember(failure.details) { sanitizeReplyErrorDetails(failure.details) }
+ val detailsStateDescription =
+ stringResource(
+ if (expanded) {
+ R.string.chat_reply_failed_details_expanded
+ } else {
+ R.string.chat_reply_failed_details_collapsed
+ },
+ )
+ val colors = LocalHermesStatusColors.current
+ Surface(
+ modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("reply_error_card"),
+ shape = MaterialTheme.shapes.medium,
+ color = colors.errorContainer,
+ contentColor = colors.onErrorContainer,
+ border = BorderStroke(1.dp, colors.error),
+ ) {
+ Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.ErrorOutline, contentDescription = null)
+ Text(
+ stringResource(R.string.chat_reply_failed_title),
+ modifier =
+ Modifier
+ .weight(
+ 1f,
+ ).padding(start = 8.dp)
+ .semantics { liveRegion = LiveRegionMode.Polite },
+ style = MaterialTheme.typography.titleSmall,
+ )
+ IconButton(onClick = onDismiss) {
+ Icon(Icons.Default.Close, contentDescription = stringResource(R.string.chat_reply_failed_dismiss))
+ }
+ }
+ Text(stringResource(R.string.chat_reply_failed_description), style = MaterialTheme.typography.bodyMedium)
+ TextButton(
+ onClick = { expanded = !expanded },
+ modifier = Modifier.semantics { stateDescription = detailsStateDescription },
+ ) {
+ Icon(if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, contentDescription = null)
+ Text(stringResource(R.string.chat_reply_failed_details))
+ }
+ if (expanded) {
+ SelectionContainer {
+ Text(
+ details,
+ modifier = Modifier.heightIn(max = 200.dp).verticalScroll(rememberScrollState()),
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ )
+ }
+ }
+ Text(stringResource(R.string.chat_reply_failed_share_note), style = MaterialTheme.typography.bodySmall)
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedButton(onClick = onOpenLogs) { Text(stringResource(R.string.chat_reply_failed_logs)) }
+ OutlinedButton(onClick = { onCopy(details) }) { Text(stringResource(R.string.chat_reply_failed_copy)) }
+ OutlinedButton(
+ onClick = { onShare(details) },
+ enabled = expanded,
+ ) { Text(stringResource(R.string.chat_reply_failed_share)) }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt
index 33d24c21..431b0195 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt
@@ -104,8 +104,9 @@ fun FullBleedChatList(
hasOlderMessages: Boolean = false,
pagingSessionId: String? = null,
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
+ replyErrorContent: (@Composable () -> Unit)? = null,
) {
- if (messages.isEmpty() && !isLoading && !isAgentTyping) {
+ if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
@@ -145,6 +146,7 @@ fun FullBleedChatList(
// A single ordered definition supplies both emitted tail rows and anchor keys.
val tailItems =
buildMap Unit> {
+ replyErrorContent?.let { put("reply_error", it) }
agentStatus?.let { status ->
put("agent_status") {
AgentStatusIndicator(status = status)
diff --git a/app/src/main/res/values/reply_errors.xml b/app/src/main/res/values/reply_errors.xml
new file mode 100644
index 00000000..9c3b9ced
--- /dev/null
+++ b/app/src/main/res/values/reply_errors.xml
@@ -0,0 +1,16 @@
+
+
+ Hermes couldn\'t finish this reply
+ Something went wrong while Hermes was replying. Open the details or logs to understand what happened.
+ Details
+ Collapsed
+ Expanded
+ Copy error details
+ Open logs
+ Share diagnostics
+ Review details before sharing. Diagnostics include this error and the app version, not your conversation or attachments.
+ Dismiss error
+
+ Error details copied
+ No app available to share diagnostics
+
diff --git a/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt
index 4d4fca1c..71e116e7 100644
--- a/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt
@@ -1,9 +1,13 @@
package com.m57.hermescontrol.notification
import android.content.Context
+import com.m57.hermescontrol.data.ws.WsEvent
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
import org.junit.Test
/**
@@ -104,4 +108,59 @@ class ChatNotificationServiceTest {
atomicBoolean.get(),
)
}
+
+ @Test
+ fun `terminal reply error uses generic failure notification without success affordances`() {
+ val rawDiagnostic = "Authorization: Bearer secret-token"
+ val plan =
+ messageCompleteNotificationPlan(
+ event =
+ WsEvent.MessageComplete(
+ text = rawDiagnostic,
+ sessionId = "runtime-1",
+ storedSessionId = "stored-1",
+ completionId = "completion-1",
+ rawPayload =
+ mapOf(
+ "status" to "error",
+ "error" to rawDiagnostic,
+ ),
+ ),
+ targetSessionId = "stored-1",
+ newMessageText = "New message",
+ failureText = "Hermes couldn't finish this reply",
+ )
+
+ assertEquals("Hermes couldn't finish this reply", plan.text)
+ assertFalse(plan.text.contains(rawDiagnostic))
+ assertEquals("stored-1", plan.sessionId)
+ assertFalse(plan.isReplyMessage)
+ assertFalse(plan.allowInlineReply)
+ assertNull(plan.completionId)
+ assertNull(plan.correlationText)
+ }
+
+ @Test
+ fun `successful completion keeps reply correlation and inline reply`() {
+ val plan =
+ messageCompleteNotificationPlan(
+ event =
+ WsEvent.MessageComplete(
+ text = "Answer\ncontinued",
+ sessionId = "runtime-1",
+ completionId = "completion-1",
+ rawPayload = mapOf("status" to "ok"),
+ ),
+ targetSessionId = "stored-1",
+ newMessageText = "New message",
+ failureText = "Hermes couldn't finish this reply",
+ )
+
+ assertEquals("Answer continued", plan.text)
+ assertEquals("stored-1", plan.sessionId)
+ assertTrue(plan.isReplyMessage)
+ assertTrue(plan.allowInlineReply)
+ assertEquals("completion-1", plan.completionId)
+ assertEquals("Answer\ncontinued", plan.correlationText)
+ }
}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatReplyErrorTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatReplyErrorTest.kt
new file mode 100644
index 00000000..9ce48c95
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatReplyErrorTest.kt
@@ -0,0 +1,315 @@
+package com.m57.hermescontrol.ui.chat
+
+import com.m57.hermescontrol.data.ws.WsEvent
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ChatReplyErrorTest {
+ @Test
+ fun failedCompletionKeepsPartialReplyAndStopsThinking() {
+ val partial =
+ ChatMessage(id = "partial", role = MessageRole.ASSISTANT, content = "Partial reply", isStreaming = true)
+ val state =
+ ChatUiState(
+ currentSessionId = "runtime",
+ isAgentTyping = true,
+ isThinking = true,
+ streamingMessage = partial,
+ )
+ val streaming = StreamingState(streamingMessage = partial, isThinking = true)
+
+ val event =
+ WsEvent.MessageComplete(
+ text = "Partial reply",
+ sessionId = "runtime",
+ completionId = "completion-failed-1",
+ rawPayload = mapOf("status" to "error", "error" to "Provider failed", "partial" to true),
+ )
+ val result = ChatWsEventReducer.reduce(state, streaming, event, "runtime")
+
+ assertFalse(result.state.isThinking)
+ assertEquals(
+ "Partial reply",
+ result.state.messages
+ .single { it.id == "partial" }
+ .content,
+ )
+ assertNotNull(result.state.replyFailure)
+ assertFalse(result.state.isAgentTyping)
+ assertNull(result.streamingState.streamingMessage)
+ assertEquals(
+ "completion-failed-1",
+ result.state.messages
+ .single { it.id == "partial" }
+ .completionId,
+ )
+ assertTrue(result.effects.none { it is ReducerEffect.PersistMessage })
+ }
+
+ @Test
+ fun terminalErrorIsNeverPersistedAsAssistantProse() {
+ val event =
+ WsEvent.MessageComplete(
+ text = "Provider refused API_KEY=private-value",
+ sessionId = "runtime",
+ rawPayload = mapOf("status" to "error"),
+ )
+ val result = ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), event, "runtime")
+ assertNotNull(result.state.replyFailure)
+ assertFalse(
+ result.state.replyFailure!!
+ .details
+ .contains("private-value"),
+ )
+ assertTrue(result.state.messages.isEmpty())
+ assertTrue(result.effects.none { it is ReducerEffect.PersistMessage })
+ }
+
+ @Test
+ fun errorWithoutPartialFlagStillKeepsAlreadyStreamedProse() {
+ val streaming =
+ StreamingState(streamingMessage = ChatMessage(role = MessageRole.ASSISTANT, content = "Visible"))
+ val event = WsEvent.MessageComplete("Error!", null, rawPayload = mapOf("status" to "error"))
+ val result = ChatWsEventReducer.reduce(ChatUiState(), streaming, event)
+ assertEquals(
+ "Visible",
+ result.state.messages
+ .single()
+ .content,
+ )
+ assertNotNull(result.state.replyFailure)
+ }
+
+ @Test
+ fun otherSessionCannotShowFailureOrChangeStreaming() {
+ val state = ChatUiState(isAgentTyping = true)
+ val streaming = StreamingState(isThinking = true)
+ val event = WsEvent.MessageComplete("Error", "other", rawPayload = mapOf("status" to "error"))
+ val result = ChatWsEventReducer.reduce(state, streaming, event, "current")
+ assertEquals(state, result.state)
+ assertEquals(streaming, result.streamingState)
+ assertTrue(result.effects.isEmpty())
+ }
+
+ @Test
+ fun missingPartialTextPreservesAlreadyVisibleOutput() {
+ for (text in listOf("", " ")) {
+ val streaming =
+ StreamingState(
+ streamingMessage =
+ ChatMessage(
+ id = "visible",
+ role = MessageRole.ASSISTANT,
+ content = "Visible output",
+ ),
+ )
+ val event =
+ WsEvent.MessageComplete(
+ text,
+ null,
+ rawPayload = mapOf("status" to "error", "partial" to true),
+ )
+ val result = ChatWsEventReducer.reduce(ChatUiState(), streaming, event)
+ assertEquals(
+ "Visible output",
+ result.state.messages
+ .single()
+ .content,
+ )
+ }
+ }
+
+ @Test
+ fun partialConversationIsNeverUsedAsDiagnosticFallback() {
+ val event =
+ WsEvent.MessageComplete(
+ "Private conversational content",
+ null,
+ rawPayload = mapOf("status" to "error", "partial" to true),
+ )
+ val result = ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), event)
+ assertEquals(
+ "Private conversational content",
+ result.state.messages
+ .single()
+ .content,
+ )
+ assertFalse(
+ result.state.replyFailure!!
+ .details
+ .contains("Private conversational content"),
+ )
+ assertTrue(
+ result.state.replyFailure!!
+ .details
+ .isNotBlank(),
+ )
+ }
+
+ @Test
+ fun normalReplyAndGatewayErrorAreNotTerminalReplyFailures() {
+ for (event in listOf(WsEvent.MessageComplete("Success", null), WsEvent.GatewayError("Connection failed"))) {
+ val result = ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), event)
+ assertNull(result.state.replyFailure)
+ }
+ }
+
+ @Test
+ fun nextTurnClearsFailureButDoneDoesNot() {
+ val failure = ReplyFailure("Failed")
+ val failedPartial =
+ ChatMessage(id = "failed-partial", role = MessageRole.ASSISTANT, content = "Partial")
+ val state =
+ ChatUiState(
+ messages = listOf(ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep"), failedPartial),
+ replyFailure = failure,
+ replyFailureProjection = ReplyFailureProjection("failed-partial", failure.id),
+ )
+ val done = ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageDone(null))
+ assertEquals(failure, done.state.replyFailure)
+ val start = ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageStart(null))
+ assertNull(start.state.replyFailure)
+ assertNull(start.state.replyFailureProjection)
+ assertEquals(listOf("normal"), start.state.messages.map { it.id })
+ }
+
+ @Test
+ fun retainedFailureWithEmptyAssistantNeverInheritsUnrelatedStream() {
+ val normal = ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep")
+ val stale =
+ ChatMessage(
+ id = "unrelated-stream",
+ role = MessageRole.ASSISTANT,
+ content = "Stale text",
+ reasoningText = "Stale reasoning",
+ isStreaming = true,
+ )
+ val state =
+ ChatUiState(
+ messages = listOf(normal),
+ isAgentTyping = true,
+ isThinking = true,
+ thinkingText = "Stale thinking",
+ streamingMessage = stale,
+ )
+
+ val result =
+ ChatWsEventReducer.reduceRetainedReplyFailure(
+ state = state,
+ inflight = mapOf("assistant" to "", "status" to "error", "error" to "Provider failed"),
+ currentSessionId = "runtime",
+ )
+
+ assertEquals(listOf(normal), result.state.messages)
+ assertFalse(result.state.isAgentTyping)
+ assertFalse(result.state.isThinking)
+ assertEquals("", result.state.thinkingText)
+ assertNull(result.state.streamingMessage)
+ assertEquals(StreamingState(), result.streamingState)
+ assertNotNull(result.state.replyFailure)
+ assertNull(result.state.replyFailureProjection?.messageId)
+ }
+
+ @Test
+ fun duplicateRetainedFailureKeepsOneStableProjection() {
+ val normal = ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep")
+ val inflight =
+ mapOf("assistant" to "Retained partial", "status" to "error", "error" to "Provider failed")
+
+ val first =
+ ChatWsEventReducer.reduceRetainedReplyFailure(
+ ChatUiState(messages = listOf(normal)),
+ inflight,
+ "runtime",
+ )
+ val second = ChatWsEventReducer.reduceRetainedReplyFailure(first.state, inflight, "runtime")
+
+ assertEquals(2, second.state.messages.size)
+ assertEquals(listOf("Keep", "Retained partial"), second.state.messages.map { it.content })
+ assertEquals(first.state.replyFailureProjection, second.state.replyFailureProjection)
+ assertEquals(first.state.replyFailure?.id, second.state.replyFailure?.id)
+ }
+
+ @Test
+ fun duplicateLiveFailureKeepsOneStableProjection() {
+ val normal = ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep")
+ val partial =
+ ChatMessage(id = "live-partial", role = MessageRole.ASSISTANT, content = "Partial", isStreaming = true)
+ val event =
+ WsEvent.MessageComplete(
+ text = "Partial",
+ sessionId = "runtime",
+ completionId = "completion-1",
+ rawPayload = mapOf("status" to "error", "error" to "Provider failed", "partial" to true),
+ )
+
+ val first =
+ ChatWsEventReducer.reduce(
+ ChatUiState(messages = listOf(normal), currentSessionId = "runtime"),
+ StreamingState(streamingMessage = partial),
+ event,
+ "runtime",
+ )
+ val second = ChatWsEventReducer.reduce(first.state, first.streamingState, event, "runtime")
+
+ assertEquals(listOf("normal", "live-partial"), second.state.messages.map { it.id })
+ assertEquals(first.state.replyFailureProjection, second.state.replyFailureProjection)
+ assertEquals(first.state.replyFailure?.id, second.state.replyFailure?.id)
+ }
+
+ @Test
+ fun liveFailureThenResumeUpdatesSameProjection() {
+ val partial =
+ ChatMessage(
+ id = "live-partial",
+ role = MessageRole.ASSISTANT,
+ content = "Live partial",
+ isStreaming = true,
+ )
+ val live =
+ ChatWsEventReducer.reduce(
+ ChatUiState(currentSessionId = "stored"),
+ StreamingState(streamingMessage = partial),
+ WsEvent.MessageComplete(
+ text = "Live partial",
+ sessionId = "runtime",
+ completionId = "completion-1",
+ rawPayload = mapOf("status" to "error", "error" to "Provider failed", "partial" to true),
+ ),
+ "runtime",
+ )
+
+ val resumed =
+ ChatWsEventReducer.reduceRetainedReplyFailure(
+ live.state,
+ mapOf("assistant" to "Retained partial", "status" to "error", "error" to "Provider failed"),
+ "runtime",
+ )
+
+ assertEquals(1, resumed.state.messages.size)
+ assertEquals(
+ "live-partial",
+ resumed.state.messages
+ .single()
+ .id,
+ )
+ assertEquals(
+ "Retained partial",
+ resumed.state.messages
+ .single()
+ .content,
+ )
+ assertEquals(
+ "completion-1",
+ resumed.state.messages
+ .single()
+ .completionId,
+ )
+ assertEquals(live.state.replyFailureProjection, resumed.state.replyFailureProjection)
+ assertEquals(live.state.replyFailure?.id, resumed.state.replyFailure?.id)
+ }
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
index 0bdb03a7..8bd9a1c1 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
@@ -460,6 +460,104 @@ class ChatViewModelTest {
assertEquals("op-1218", operation?.opId)
}
+ @Test
+ fun sessionResume_restoresRetainedFailureAsNonDurablePartial() =
+ runTest {
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+ viewModel.switchSession("stored-session")
+ advanceUntilIdle()
+ val resumeRequestId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second
+
+ mockEventsFlow.emit(
+ WsEvent.RpcResult(
+ resumeRequestId,
+ mapOf(
+ "session_id" to "runtime-session",
+ "resumed" to "stored-session",
+ "inflight" to
+ mapOf(
+ "assistant" to "Retained partial answer",
+ "user" to "Private user prompt",
+ "streaming" to false,
+ "status" to "error",
+ "error" to "Provider failed",
+ "recoverable" to true,
+ "error_surface" to mapOf("provider" to "example", "code" to "rate_limit"),
+ ),
+ ),
+ ),
+ )
+ advanceUntilIdle()
+
+ assertEquals("stored-session", viewModel.uiState.value.currentSessionId)
+ assertEquals(
+ "Retained partial answer",
+ viewModel.uiState.value.messages
+ .last()
+ .content,
+ )
+ assertEquals(
+ "code: rate_limit\nprovider: example\nProvider failed",
+ viewModel.uiState.value.replyFailure
+ ?.details,
+ )
+ assertFalse(
+ viewModel.uiState.value.replyFailure!!
+ .details
+ .contains("Private user prompt"),
+ )
+ assertTrue(
+ fakeRepo.dao
+ .getMessagesForSession("stored-session")
+ .none { it.content == "Retained partial answer" },
+ )
+ }
+
+ @Test
+ fun staleSessionResumeFailureCannotClearHealthyCurrentStream() =
+ runTest {
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+ viewModel.switchSession("stored-old")
+ advanceUntilIdle()
+ val staleResumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second
+
+ viewModel.switchSession("stored-current")
+ advanceUntilIdle()
+ mockEventsFlow.emit(WsEvent.MessageStart("stored-current"))
+ mockEventsFlow.emit(WsEvent.MessageToken("Healthy current stream", "stored-current"))
+ advanceUntilIdle()
+
+ mockEventsFlow.emit(
+ WsEvent.RpcResult(
+ staleResumeId,
+ mapOf(
+ "session_id" to "runtime-old",
+ "inflight" to
+ mapOf(
+ "assistant" to "Old failed partial",
+ "status" to "error",
+ "error" to "Old provider failure",
+ ),
+ ),
+ ),
+ )
+ advanceUntilIdle()
+
+ assertEquals("stored-current", viewModel.uiState.value.currentSessionId)
+ assertNull(viewModel.uiState.value.replyFailure)
+ assertEquals(
+ "Healthy current stream",
+ viewModel.streamingState.value.streamingMessage
+ ?.content,
+ )
+ assertTrue(
+ viewModel.uiState.value.messages
+ .none { it.content == "Old failed partial" },
+ )
+ }
+
private fun connectionOperationSnapshot(
sessionId: String,
seq: Long = 1L,
@@ -1387,6 +1485,96 @@ class ChatViewModelTest {
// ── Streaming tests ──────────────────────────────────────────────────────
+ @Test
+ fun terminalReplyFailureIsScopedDismissibleAndNeverResends() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ mockEventsFlow.emit(WsEvent.MessageToken("Partial", sessionId))
+ advanceUntilIdle()
+ mockEventsFlow.emit(
+ WsEvent.MessageComplete("Other error", "other-session", rawPayload = mapOf("status" to "error")),
+ )
+ advanceUntilIdle()
+ assertTrue(viewModel.uiState.value.isAgentTyping)
+ assertNull(viewModel.uiState.value.replyFailure)
+ assertEquals(
+ "Partial",
+ viewModel.streamingState.value.streamingMessage
+ ?.content,
+ )
+ mockEventsFlow.emit(
+ WsEvent.MessageComplete("Provider error", sessionId, rawPayload = mapOf("status" to "error")),
+ )
+ advanceUntilIdle()
+ val failure = viewModel.uiState.value.replyFailure!!
+ assertFalse(viewModel.uiState.value.isAgentTyping)
+ assertFalse(viewModel.uiState.value.isThinking)
+ assertTrue(
+ viewModel.uiState.value.messages
+ .any { it.content == "Partial" },
+ )
+ assertFalse(
+ viewModel.uiState.value.messages
+ .any { it.content == "Provider error" },
+ )
+ viewModel.dismissReplyFailure("stale-card")
+ advanceUntilIdle()
+ assertEquals(failure, viewModel.uiState.value.replyFailure)
+ viewModel.dismissReplyFailure(failure.id)
+ advanceUntilIdle()
+ assertNull(viewModel.uiState.value.replyFailure)
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun unscopedFailureCannotCrossSessionSwitch() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ val failure = WsEvent.MessageComplete("Error", null, rawPayload = mapOf("status" to "error"))
+ mockEventsFlow.emit(failure)
+ advanceUntilIdle()
+ assertNull(viewModel.uiState.value.replyFailure)
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.switchSession("another-session")
+ advanceUntilIdle()
+ // A delayed start without identity cannot authorize a failed turn on this chat.
+ mockEventsFlow.emit(WsEvent.MessageStart(null))
+ advanceUntilIdle()
+ mockEventsFlow.emit(failure)
+ advanceUntilIdle()
+ assertNull(viewModel.uiState.value.replyFailure)
+ assertFalse(
+ viewModel.uiState.value.messages
+ .any { it.content == "Error" },
+ )
+ }
+
+ @Test
+ fun unscopedFailureAfterForeignStartCannotUsePreviousSessionPin() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ mockEventsFlow.emit(WsEvent.MessageToken("Current partial", sessionId))
+ advanceUntilIdle()
+ mockEventsFlow.emit(WsEvent.MessageStart("foreign-session"))
+ mockEventsFlow.emit(
+ WsEvent.MessageComplete("Foreign diagnostic", null, rawPayload = mapOf("status" to "error")),
+ )
+ advanceUntilIdle()
+ assertNull(viewModel.uiState.value.replyFailure)
+ assertEquals(
+ "Current partial",
+ viewModel.streamingState.value.streamingMessage
+ ?.content,
+ )
+ assertFalse(
+ viewModel.uiState.value.messages
+ .any { it.content == "Foreign diagnostic" },
+ )
+ }
+
@Test
fun testMessageStreamingFlow() =
runTest {
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ReplyFailureDetailsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ReplyFailureDetailsTest.kt
new file mode 100644
index 00000000..fa646218
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ReplyFailureDetailsTest.kt
@@ -0,0 +1,61 @@
+package com.m57.hermescontrol.ui.chat
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ReplyFailureDetailsTest {
+ @Test(timeout = 2_000)
+ fun longNonMatchingDiagnosticDoesNotBlockUi() {
+ assertEquals("x".repeat(8_192), sanitizeReplyErrorDetails("x".repeat(100_000)))
+ }
+
+ @Test
+ fun redactsCredentialsAndUrlsBeforeDisplayOrExport() {
+ val raw =
+ """
+ Provider failed with HTTP 429
+ Authorization: Bearer private-bearer
+ Cookie: sid=private-cookie; csrf=private-csrf
+ {"api_key":"private-key", "password": "private password", "access_token": "private-access"}
+ https://user:private-pass@example.test/path?token=private-query
+ OPENAI_API_KEY=private-env
+ -----BEGIN PRIVATE KEY-----
+ private-key-material
+ -----END PRIVATE KEY-----
+ """.trimIndent()
+ val details = sanitizeReplyErrorDetails(raw)
+ assertTrue(details.contains("HTTP 429"))
+ assertTrue(details.contains("[REDACTED]"))
+ assertFalse(details.contains("private-"))
+ assertFalse(details.contains("private password"))
+ }
+
+ @Test
+ fun limitsDetailsAndHandlesMissingMessage() {
+ assertTrue(sanitizeReplyErrorDetails("x".repeat(100_000)).length <= 8_192)
+ assertEquals("Unknown gateway error", sanitizeReplyErrorDetails(null))
+ assertEquals("Unknown gateway error", sanitizeReplyErrorDetails(" "))
+ }
+
+ @Test
+ fun structuredSurfaceExportsOnlyAllowedMetadataAndSanitizedError() {
+ val failure =
+ replyFailureFromPayload(
+ mapOf(
+ "status" to "error",
+ "error" to "HTTP 429",
+ "error_surface" to
+ mapOf(
+ "provider" to "example",
+ "code" to "rate_limit",
+ "unknown_field" to "private-information",
+ "message" to "private-conversation",
+ ),
+ ),
+ "fallback",
+ )
+ assertEquals("code: rate_limit\nprovider: example\nHTTP 429", failure?.details)
+ }
+}
From f3921957b5017df5e2bd639e129a6ddb2619b0e9 Mon Sep 17 00:00:00 2001
From: m57 <41645758+Hy4ri@users.noreply.github.com>
Date: Thu, 24 Sep 2026 22:17:10 +0300
Subject: [PATCH 004/107] fix(chat): reconcile live assistant reply carrying
verifier footer with REST echo (#1241) (#1256)
---
.../ui/chat/ChatServerMessageMapper.kt | 37 ++++++++
.../ui/chat/ChatTranscriptMerge.kt | 62 ++++++++++++-
.../ui/chat/ChatVerifierFooter.kt | 80 +++++++++++++++++
.../ui/chat/ChatPagingMergeTest.kt | 44 +++++++++
.../ui/chat/ChatVerifierFooterTest.kt | 90 +++++++++++++++++++
5 files changed, 310 insertions(+), 3 deletions(-)
create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooter.kt
create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooterTest.kt
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt
index 394dc888..79a9972b 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt
@@ -49,6 +49,7 @@ internal fun mapServerMessages(
}
val wsCompletionIdByRestIndex = mutableMapOf()
+ val verifierContentByRestIndex = mutableMapOf()
if (!isPagingOlder) {
// #129: reserve durable identity before matching any repeated live prose.
// REST-only recovery remains fail-closed when the target row is absent.
@@ -89,6 +90,27 @@ internal fun mapServerMessages(
remainingWs.removeAt(wsIdx).completionId?.let { wsCompletionIdByRestIndex[i] = it }
}
}
+ // Fallback: match live assistant containing a file-mutation verifier footer against
+ // its footer-free REST counterpart (issue #1241).
+ for (i in messages.indices.reversed()) {
+ if (remainingWs.isEmpty()) break
+ if (i in wsCompletionIdByRestIndex || restIdAt(i) in reservedRestIds) continue
+ val m = messages[i]
+ if (m.role?.lowercase() in listOf("user", "system", "tool")) continue
+ val rawContent = m.displayContentText ?: m.contentText
+ if (rawContent.isBlank()) continue
+ val canonicalContent =
+ if (rawContent.contains("MEDIA:")) HostMediaExtractor.strip(rawContent).trim() else rawContent.trim()
+ val wsIdx = remainingWs.indexOfLast { ChatVerifierFooter.matchesBase(it.content, canonicalContent) }
+ if (wsIdx >= 0) {
+ val matchedWs = remainingWs.removeAt(wsIdx)
+ matchedWs.completionId?.let { wsCompletionIdByRestIndex[i] = it }
+ // Propagate the richer live footer content to the REST row so canonical mapping keeps it
+ if (ChatVerifierFooter.split(matchedWs.content) != null) {
+ verifierContentByRestIndex[i] = matchedWs.content
+ }
+ }
+ }
}
// Reasoning follows the same reserved identities as completions, never a reusable text lookup.
@@ -119,6 +141,19 @@ internal fun mapServerMessages(
val match = remaining.indexOfLast { it.content.trim() == content }
if (match >= 0) reasoningSources[index] = remaining.removeAt(match)
}
+ for (index in messages.indices.reversed()) {
+ val row = messages[index]
+ val displayContent = row.displayContentText ?: row.contentText
+ if (reasoningSources[index] != null || displayContent.isBlank() ||
+ row.role?.lowercase() in listOf("user", "system", "tool") ||
+ liveByExactId[restIdAt(index)]?.completionId != null || index in wsCompletionIdByRestIndex
+ ) {
+ continue
+ }
+ val content = HostMediaExtractor.strip(displayContent).trim()
+ val match = remaining.indexOfLast { ChatVerifierFooter.matchesBase(it.content, content) }
+ if (match >= 0) reasoningSources[index] = remaining.removeAt(match)
+ }
}
val mapped = mutableListOf()
@@ -166,6 +201,8 @@ internal fun mapServerMessages(
var finalContent =
if (role == MessageRole.USER) {
stripGatewayAttachedContext(stripGatewaySteerWrapper(rawContent))
+ } else if (role == MessageRole.ASSISTANT && verifierContentByRestIndex.containsKey(index)) {
+ verifierContentByRestIndex[index] ?: rawContent
} else {
rawContent
}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt
index 1eb48730..fcb7dc9b 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt
@@ -185,6 +185,14 @@ internal class TranscriptComparison(
val tb = trimmed.getOrPut(b.content) { b.content.trim() }
if (a.role == MessageRole.USER && (ta.startsWith("/") || tb.startsWith("/"))) return false
if (ta == tb) return true
+ if (a.role == MessageRole.ASSISTANT &&
+ (a.completionId != null || b.completionId != null) &&
+ a.completionId == b.completionId &&
+ ta.isNotBlank() && tb.isNotBlank() &&
+ ChatVerifierFooter.matchesBase(ta, tb)
+ ) {
+ return true
+ }
if (a.role == MessageRole.USER &&
captions.getOrPut(ta) { stripAttachmentRefLines(ta) } ==
captions.getOrPut(tb) { stripAttachmentRefLines(tb) }
@@ -250,7 +258,17 @@ internal fun matchTranscriptMessages(
) &&
// Distinct IDs within the same source are separate occurrences, not echoes.
((message.canonicalRestId == null) != (other.canonicalRestId == null)) &&
- comparison.same(message, other)
+ (
+ comparison.same(message, other) ||
+ (
+ allowAssistantContentMatches &&
+ message.role == MessageRole.ASSISTANT &&
+ message.content.isNotBlank() && other.content.isNotBlank() &&
+ message.completionId != null &&
+ message.completionId == other.completionId &&
+ ChatVerifierFooter.matchesBase(message.content, other.content)
+ )
+ )
}
if (match != null) {
used[match] = true
@@ -341,6 +359,20 @@ internal fun mergeCachedTranscriptPage(
incoming
.mapIndexedNotNull { index, message ->
val match = matches[index] ?: return@mapIndexedNotNull null
+ val preservedContent =
+ if (match.role == MessageRole.ASSISTANT &&
+ ChatVerifierFooter.split(match.content) != null &&
+ ChatVerifierFooter.matchesBase(match.content, message.content)
+ ) {
+ match.content
+ } else if (message.role == MessageRole.ASSISTANT &&
+ ChatVerifierFooter.split(message.content) != null &&
+ ChatVerifierFooter.matchesBase(message.content, match.content)
+ ) {
+ message.content
+ } else {
+ null
+ }
val rich =
when {
// A late historical snapshot may contribute safe metadata, but it must
@@ -367,6 +399,7 @@ internal fun mergeCachedTranscriptPage(
match.id to
rich.copy(
id = match.id,
+ content = preservedContent ?: rich.content,
restId = match.canonicalRestId ?: message.canonicalRestId,
completionId = match.completionId ?: message.completionId,
displayKind = match.displayKind ?: message.displayKind,
@@ -408,9 +441,18 @@ internal fun mergeTranscriptWithLive(
}
preserveLiveIds && match != null && !match.id.startsWith("rest-") -> {
+ val mergedContent =
+ if (match.role == MessageRole.ASSISTANT &&
+ ChatVerifierFooter.split(match.content) != null &&
+ ChatVerifierFooter.matchesBase(match.content, message.content)
+ ) {
+ match.content
+ } else {
+ message.content
+ }
match.copy(
restId = message.canonicalRestId ?: match.canonicalRestId,
- content = message.content,
+ content = mergedContent,
timestamp = message.timestamp,
isStreaming = message.isStreaming,
reasoningText = message.reasoningText.ifBlank { match.reasoningText },
@@ -426,7 +468,21 @@ internal fun mergeTranscriptWithLive(
}
else -> {
- message.copy(completionId = message.completionId ?: match?.completionId, isHistoricalCache = false)
+ val mergedContent =
+ if (message.role == MessageRole.ASSISTANT &&
+ match != null &&
+ ChatVerifierFooter.split(match.content) != null &&
+ ChatVerifierFooter.matchesBase(match.content, message.content)
+ ) {
+ match.content
+ } else {
+ message.content
+ }
+ message.copy(
+ content = mergedContent,
+ completionId = message.completionId ?: match?.completionId,
+ isHistoricalCache = false,
+ )
}
}
}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooter.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooter.kt
new file mode 100644
index 00000000..3651c7b1
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooter.kt
@@ -0,0 +1,80 @@
+package com.m57.hermescontrol.ui.chat
+
+/**
+ * Utility to recognize and extract File-mutation verifier footers appended to
+ * assistant responses on live channels.
+ *
+ * Pattern emitted by backend `turn_explainers._format_file_mutation_failure_footer`:
+ * ⚠️ File-mutation verifier: N file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run `git status` or `read_file` to confirm what actually landed.
+ * • `path` — [tool] error_preview
+ * • … and N more
+ */
+internal object ChatVerifierFooter {
+ private const val HEADER_PREFIX = "⚠️ File-mutation verifier:"
+ private const val HEADER_NO_EMOJI_PREFIX = "File-mutation verifier:"
+
+ data class SplitResult(
+ val body: String,
+ val footer: String,
+ )
+
+ /**
+ * Splits [content] into (body, footer) if it cleanly ends with a recognized
+ * File-mutation verifier block. If not, returns null.
+ */
+ fun split(content: String): SplitResult? {
+ val trimmed = content.trimEnd()
+ if (trimmed.isBlank()) return null
+
+ val headerIdx =
+ trimmed.lastIndexOf(HEADER_PREFIX).let { idx ->
+ if (idx >= 0) idx else trimmed.lastIndexOf(HEADER_NO_EMOJI_PREFIX)
+ }
+ if (headerIdx <= 0) return null
+
+ // Must be preceded by a newline (standalone block)
+ val charBefore = trimmed[headerIdx - 1]
+ if (charBefore != '\n') return null
+
+ val headerAndRest = trimmed.substring(headerIdx)
+ val lines = headerAndRest.lines()
+ val firstLine = lines.first().trim()
+
+ // Verify first line structure: "⚠️ File-mutation verifier: ... FAILED this turn..."
+ if (!firstLine.contains("file edit(s) FAILED this turn") &&
+ !firstLine.contains("file(s) were NOT modified this turn")
+ ) {
+ return null
+ }
+
+ // Remaining lines must be bullet items or continuation lines
+ for (i in 1 until lines.size) {
+ val line = lines[i]
+ if (line.isBlank()) continue
+ val trimmedLine = line.trimStart()
+ // Bullets typically start with •
+ if (!trimmedLine.startsWith("•") && !trimmedLine.startsWith("-") && !line.startsWith(" ")) {
+ return null
+ }
+ }
+
+ val body = trimmed.substring(0, headerIdx).trimEnd()
+ if (body.isBlank()) return null
+
+ val footer = trimmed.substring(headerIdx)
+ return SplitResult(body = body, footer = footer)
+ }
+
+ /**
+ * Returns true if [a] and [b] have the same underlying text once any
+ * trailing file-mutation verifier footer is removed.
+ */
+ fun matchesBase(
+ a: String,
+ b: String,
+ ): Boolean {
+ val cleanA = split(a)?.body ?: a.trim()
+ val cleanB = split(b)?.body ?: b.trim()
+ return cleanA == cleanB
+ }
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt
index 6a3297a9..815a315f 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt
@@ -655,6 +655,50 @@ class ChatPagingMergeTest {
assertTrue(sameLogicalMessage(canonical, live))
}
+ @Test
+ fun verifierFooterReplyMatchesCanonicalCounterpartWithoutFooter() {
+ val footer =
+ """
+ ⚠️ File-mutation verifier: 1 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run git status or read_file to confirm what actually landed.
+ • /tmp/skill_lang_audit.py — [write_file] Write denied: '/tmp/skill_lang_audit.py' is outside HERMES_WRITE_SAFE_ROOT (/opt/data). Unset the variable or add this path's directory prefix.
+ """.trimIndent()
+ val body = "I completed the audit. Here are the findings."
+ val liveAssistant =
+ ChatMessage(
+ id = "uuid-123",
+ role = MessageRole.ASSISTANT,
+ content = "$body\n\n$footer",
+ completionId = "comp-1",
+ )
+ val serverRow =
+ SessionMessage(
+ id = 42,
+ role = "assistant",
+ content = JsonPrimitive(body),
+ )
+
+ // 1. mapServerMessages correctly matches liveAssistant and acquires comp-1 and rich footer content
+ val mapped = mapServerMessages("session", listOf(serverRow), 0, true, listOf(liveAssistant))
+ assertEquals(1, mapped.size)
+ val canonical = mapped.single()
+ assertEquals("rest-session-42", canonical.id)
+ assertEquals("comp-1", canonical.completionId)
+ assertTrue("Canonical mapped row should retain verifier footer", canonical.content.contains(footer))
+
+ // 2. mergeTranscriptWithLive merges into a single message preserving footer and acquiring restId
+ val merged =
+ mergeTranscriptWithLive(
+ restMessages = mapped,
+ currentMessages = listOf(liveAssistant),
+ preserveLiveIds = true,
+ )
+ assertEquals(1, merged.size)
+ val single = merged.single()
+ assertEquals("uuid-123", single.id)
+ assertEquals("rest-session-42", single.restId)
+ assertTrue("Merged message must keep the verifier warning footer", single.content.contains(footer))
+ }
+
@Test
fun permanentlyLocalRowsDoNotMatchCanonicalRowsByContent() {
val localFeedback =
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooterTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooterTest.kt
new file mode 100644
index 00000000..7e2fcc63
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooterTest.kt
@@ -0,0 +1,90 @@
+package com.m57.hermescontrol.ui.chat
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ChatVerifierFooterTest {
+ private val sampleFooter =
+ """
+ ⚠️ File-mutation verifier: 1 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run git status or read_file to confirm what actually landed.
+ • /tmp/skill_lang_audit.py — [write_file] Write denied: '/tmp/skill_lang_audit.py' is outside HERMES_WRITE_SAFE_ROOT (/opt/data). Unset the variable or add this path's directory prefix.
+ """.trimIndent()
+
+ private val backtickedFooter =
+ """
+ ⚠️ File-mutation verifier: 1 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run `git status` or `read_file` to confirm what actually landed.
+ • `/tmp/skill_lang_audit.py` — [write_file] Write denied: '/tmp/skill_lang_audit.py' is outside HERMES_WRITE_SAFE_ROOT (/opt/data). Unset the variable or add this path's directory prefix.
+ """.trimIndent()
+
+ @Test
+ fun `split extracts body and footer for exact reporter format`() {
+ val body = "I completed the audit. Here are the findings."
+ val full = "$body\n\n$sampleFooter"
+
+ val result = ChatVerifierFooter.split(full)
+ assertNotNull(result)
+ assertEquals(body, result?.body)
+ assertEquals(sampleFooter, result?.footer?.trim())
+ }
+
+ @Test
+ fun `split extracts body and footer for backend backticked format`() {
+ val body = "Audit summary:\n- item 1\n- item 2"
+ val full = "$body\n\n$backtickedFooter"
+
+ val result = ChatVerifierFooter.split(full)
+ assertNotNull(result)
+ assertEquals(body, result?.body)
+ assertEquals(backtickedFooter, result?.footer?.trim())
+ }
+
+ @Test
+ fun `split handles multiple bullets and overflow note`() {
+ val footer =
+ """
+ ⚠️ File-mutation verifier: 3 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run `git status` or `read_file` to confirm what actually landed.
+ • `/a.py` — [patch] failed
+ • `/b.py` — [patch] failed
+ • … and 1 more
+ """.trimIndent()
+ val body = "Done"
+ val result = ChatVerifierFooter.split("$body\n\n$footer")
+ assertNotNull(result)
+ assertEquals("Done", result?.body)
+ }
+
+ @Test
+ fun `split rejects footer when body is empty`() {
+ val result = ChatVerifierFooter.split(sampleFooter)
+ assertNull(result)
+ }
+
+ @Test
+ fun `split rejects footer when inside a line without newline before it`() {
+ val text = "Look at this: ⚠️ File-mutation verifier: 1 file edit(s) FAILED this turn despite any wording..."
+ val result = ChatVerifierFooter.split(text)
+ assertNull(result)
+ }
+
+ @Test
+ fun `split rejects text with unrelated trailing prose`() {
+ val full = "Body text\n\n$sampleFooter\nAnd here is more stuff after the footer."
+ val result = ChatVerifierFooter.split(full)
+ assertNull(result)
+ }
+
+ @Test
+ fun `matchesBase compares body ignoring verifier footer`() {
+ val body = "Here is the response without errors."
+ val withFooter = "$body\n\n$sampleFooter"
+
+ assertTrue(ChatVerifierFooter.matchesBase(body, withFooter))
+ assertTrue(ChatVerifierFooter.matchesBase(withFooter, body))
+ assertTrue(ChatVerifierFooter.matchesBase(withFooter, withFooter))
+ assertFalse(ChatVerifierFooter.matchesBase("Different body", withFooter))
+ }
+}
From 35e6ebc8d5a9bab7c6c46c60f24b308375106627 Mon Sep 17 00:00:00 2001
From: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
Date: Fri, 25 Sep 2026 12:26:25 +0200
Subject: [PATCH 005/107] feat(chat): add explicit busy-send modes and
recoverable queue (#1254)
Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
---
.../hermescontrol/ui/chat/ChatScreenTest.kt | 36 +
.../components/ComposerInteractionTest.kt | 171 +-
.../chat/components/PendingSendPanelTest.kt | 98 +
.../ui/settings/components/ChatSectionTest.kt | 81 +-
.../data/config/ServerStoreState.kt | 2 +
.../hermescontrol/data/local/AuthManager.kt | 7 +
.../data/local/ChatMessageDao.kt | 3 +
.../hermescontrol/data/model/BusySendMode.kt | 11 +
.../m57/hermescontrol/data/ws/WsMethods.kt | 1 +
.../ui/chat/BusySendModeLabel.kt | 15 +
.../ui/chat/ChatPersistenceRepository.kt | 4 +
.../ui/chat/ChatQueuePresentation.kt | 17 +
.../m57/hermescontrol/ui/chat/ChatScreen.kt | 27 +-
.../hermescontrol/ui/chat/ChatSendStore.kt | 149 ++
.../hermescontrol/ui/chat/ChatViewModel.kt | 1456 ++++++++++--
.../ui/chat/components/ChatComposer.kt | 14 +-
.../ui/chat/components/ComposerToolbar.kt | 83 +-
.../ui/chat/components/PendingSendPanel.kt | 103 +
.../ui/settings/SettingsSubPages.kt | 2 +
.../ui/settings/SettingsViewModel.kt | 9 +
.../settings/components/AppearanceSection.kt | 36 +
app/src/main/res/values-ar/strings.xml | 24 +-
app/src/main/res/values-ko/strings.xml | 24 +-
app/src/main/res/values-zh/strings.xml | 20 +
app/src/main/res/values/strings.xml | 23 +
.../ui/chat/ChatPersistenceRepositoryTest.kt | 18 +
.../ui/chat/ChatQueuePresentationTest.kt | 48 +
.../ui/chat/ChatSendStoreTest.kt | 207 ++
.../ui/chat/ChatUpdateCommandTest.kt | 44 +-
.../ui/chat/ChatViewModelTest.kt | 1946 ++++++++++++++++-
.../ui/chat/SlashCommandDispatchRpcTest.kt | 14 +-
.../ui/chat/fakes/FakeChatMessageDao.kt | 4 +
.../ui/settings/SettingsViewModelTest.kt | 14 +
33 files changed, 4482 insertions(+), 229 deletions(-)
create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt
create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/BusySendMode.kt
create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/BusySendModeLabel.kt
create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt
create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt
create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt
create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt
create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt
index 2b4b9731..5e5c5b79 100644
--- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt
@@ -4,16 +4,19 @@ import android.content.pm.PackageManager
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.core.content.ContextCompat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.Attachment
+import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.ui.common.ActionProgressController
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
+import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -125,4 +128,37 @@ class ChatScreenTest {
composeTestRule.onNodeWithTag("chat_input").assertIsDisplayed()
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
}
+
+ @Test
+ fun chatScreen_primarySendUsesSavedBusyDefault() {
+ val uiState =
+ ChatUiState(
+ connectionStatus = ConnectionStatus.CONNECTED,
+ isSessionReady = true,
+ busySendMode = BusySendMode.QUEUE,
+ )
+ val mockViewModel = mockk(relaxed = true)
+ every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
+ every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
+ every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
+ every { mockViewModel.connectionOperationState } returns
+ MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
+ every { mockViewModel.actionProgress } returns
+ ActionProgressController(scope = CoroutineScope(Dispatchers.Main))
+ every { mockViewModel.sendMessage(any(), any()) } returns true
+
+ composeTestRule.setContent {
+ ChatScreen(
+ onOpenDrawer = {},
+ sessionId = null,
+ viewModel = mockViewModel,
+ )
+ }
+
+ composeTestRule.onNodeWithTag("chat_input").performTextInput("Use my saved default")
+ composeTestRule.onNodeWithTag("send_button").performClick()
+
+ verify(exactly = 1) { mockViewModel.sendMessage("Use my saved default", null) }
+ verify(exactly = 0) { mockViewModel.sendMessage("Use my saved default", BusySendMode.CORRECT) }
+ }
}
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt
index 2da52438..63317410 100644
--- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat.components
+import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
@@ -12,8 +13,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.getUnclippedBoundsInRoot
-import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -24,9 +27,12 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
-import androidx.test.espresso.Espresso.pressBack
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.m57.hermescontrol.R
+import com.m57.hermescontrol.data.model.Attachment
+import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.ws.CommandCatalog
import org.junit.Assert.assertEquals
import org.junit.Rule
@@ -42,7 +48,7 @@ import org.junit.runner.RunWith
@MediumTest
class ComposerInteractionTest {
@get:Rule
- val composeTestRule = createComposeRule()
+ val composeTestRule = createAndroidComposeRule()
private var micTaps = 0
private var sends = 0
@@ -52,6 +58,158 @@ class ComposerInteractionTest {
private var photosTaps = 0
private var fileTaps = 0
+ private fun setContextualComposer(
+ text: String,
+ isMainTurnBusy: Boolean,
+ attachments: List = emptyList(),
+ onSend: () -> Unit = {},
+ onBusySend: (BusySendMode) -> Unit = {},
+ isConnected: Boolean = true,
+ pendingReasoningLevel: String? = null,
+ ) {
+ composeTestRule.setContent {
+ ChatInputBar(
+ inputFieldValue = TextFieldValue(text),
+ onInputChange = {},
+ onSend = onSend,
+ onBusySend = onBusySend,
+ onMicTap = {},
+ isListening = false,
+ isAgentTyping = isMainTurnBusy,
+ isMainTurnBusy = isMainTurnBusy,
+ isConnected = isConnected,
+ commandCatalog = CommandCatalog(),
+ isSessionReady = true,
+ pendingAttachments = attachments,
+ pendingReasoningLevel = pendingReasoningLevel,
+ )
+ }
+ }
+
+ @Test
+ fun idleDraft_showsRegularSendWithoutBusyActions() {
+ setContextualComposer(text = "hello", isMainTurnBusy = false)
+
+ composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
+ }
+
+ @Test
+ fun busyBlankDraft_hidesBusyActions() {
+ setContextualComposer(text = "", isMainTurnBusy = true)
+
+ composeTestRule.onNodeWithTag("send_button").assertDoesNotExist()
+ composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
+ }
+
+ @Test
+ fun busyTextDraft_showsSendAndQueueAndRoutesCallbacks() {
+ var regularSends = 0
+ val selected = mutableListOf()
+ setContextualComposer(
+ text = "change course",
+ isMainTurnBusy = true,
+ onSend = { regularSends++ },
+ onBusySend = selected::add,
+ )
+
+ composeTestRule.onNodeWithTag("send_button").assertIsDisplayed().performClick()
+ composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed().performClick()
+
+ composeTestRule.runOnIdle {
+ assertEquals(1, regularSends)
+ assertEquals(listOf(BusySendMode.QUEUE), selected)
+ }
+ }
+
+ @Test
+ fun busyAttachmentOnly_showsSendAndQueue() {
+ val attachment = Attachment("content://document/1", "notes.txt", "text/plain")
+ setContextualComposer(text = "", isMainTurnBusy = true, attachments = listOf(attachment))
+
+ composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
+ }
+
+ @Test
+ fun busyDisconnectedDraft_disablesSendAndQueue() {
+ setContextualComposer(text = "hello", isMainTurnBusy = true, isConnected = false)
+ composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled()
+ composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled()
+ }
+
+ @Test
+ fun busyPendingReasoning_disablesSendAndQueue() {
+ setContextualComposer(text = "hello", isMainTurnBusy = true, pendingReasoningLevel = "high")
+ composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled()
+ composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled()
+ }
+
+ @Test
+ fun busySlashDraft_usesRegularSendWithoutBusyActions() {
+ var regularSends = 0
+ setContextualComposer(
+ text = "/stop",
+ isMainTurnBusy = true,
+ onSend = { regularSends++ },
+ )
+
+ composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
+ composeTestRule.onNodeWithTag("send_button").performClick()
+ composeTestRule.runOnIdle { assertEquals(1, regularSends) }
+ }
+
+ @Test
+ fun busyBecomesIdle_hidesQueueAndPreservesTypedDraft() {
+ val busy = mutableStateOf(true)
+ composeTestRule.setContent {
+ var input by remember { mutableStateOf(TextFieldValue("draft in progress")) }
+ ChatInputBar(
+ inputFieldValue = input,
+ onInputChange = { input = it },
+ onSend = {},
+ onBusySend = {},
+ onMicTap = {},
+ isListening = false,
+ isAgentTyping = busy.value,
+ isMainTurnBusy = busy.value,
+ isConnected = true,
+ commandCatalog = CommandCatalog(),
+ isSessionReady = true,
+ )
+ }
+
+ composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
+ composeTestRule.runOnUiThread { busy.value = false }
+ composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
+ composeTestRule.onNodeWithTag("chat_input").assertTextEquals("draft in progress")
+ composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
+ }
+
+ @Test
+ fun busyDraft_narrowWidthKeepsSendAndQueueAccessible() {
+ composeTestRule.setContent {
+ Box(Modifier.width(220.dp)) {
+ ChatInputBar(
+ inputFieldValue = TextFieldValue("change course"),
+ onInputChange = {},
+ onSend = {},
+ onBusySend = {},
+ onMicTap = {},
+ isListening = false,
+ isAgentTyping = true,
+ isMainTurnBusy = true,
+ isConnected = true,
+ commandCatalog = CommandCatalog(),
+ isSessionReady = true,
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
+ }
+
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
private fun setComposer(
reasoningLevel: String? = "medium",
@@ -246,7 +404,8 @@ class ComposerInteractionTest {
"short model pill should remain content-sized, width=$pillWidth toolbar=$toolbarWidth"
}
check(actionButton.right > toolbarBounds.right - 32.dp) {
- "trailing action should stay near the toolbar edge, right=${actionButton.right} toolbarRight=${toolbarBounds.right}"
+ "trailing action should stay near the toolbar edge, " +
+ "right=${actionButton.right} toolbarRight=${toolbarBounds.right}"
}
}
@@ -337,11 +496,11 @@ class ComposerInteractionTest {
}
@Test
- fun attachmentTray_dismissesOnBackPress() {
+ fun attachmentTray_dismissesOnActivityBack() {
setComposer()
openAttachmentTray()
- pressBack()
+ composeTestRule.runOnUiThread { composeTestRule.activity.onBackPressedDispatcher.onBackPressed() }
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt
new file mode 100644
index 00000000..ab69dd29
--- /dev/null
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt
@@ -0,0 +1,98 @@
+package com.m57.hermescontrol.ui.chat.components
+
+import androidx.activity.ComponentActivity
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.m57.hermescontrol.R
+import com.m57.hermescontrol.data.model.BusySendMode
+import com.m57.hermescontrol.ui.chat.PendingSend
+import com.m57.hermescontrol.ui.chat.PendingSendState
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PendingSendPanelTest {
+ @get:Rule
+ val compose = createAndroidComposeRule()
+
+ private fun row(state: PendingSendState) =
+ PendingSend("receipt", "scope", "session", "My message", mode = BusySendMode.CORRECT, state = state)
+
+ @Test
+ fun ordinarySendNeverShowsDeliveryPanel() {
+ val state = mutableStateOf(PendingSendState.SENDING)
+ compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) }
+ compose.onNodeWithText("My message").assertDoesNotExist()
+ compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_sends)).assertDoesNotExist()
+ compose.runOnIdle { state.value = PendingSendState.ACCEPTED }
+ compose.onNodeWithText("My message").assertDoesNotExist()
+ compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_accepted)).assertDoesNotExist()
+ }
+
+ @Test
+ fun queueDisappearsWhenDispatchedWithoutAwaitingLabel() {
+ val state = mutableStateOf(PendingSendState.QUEUED)
+ compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) }
+ compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_queued)).assertIsDisplayed()
+ compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertDoesNotExist()
+ compose.runOnIdle { state.value = PendingSendState.SENDING }
+ compose.onNodeWithText("My message").assertDoesNotExist()
+ compose.runOnIdle { state.value = PendingSendState.ACCEPTED }
+ compose.onNodeWithText("My message").assertDoesNotExist()
+ }
+
+ @Test
+ fun failedAndUncertainDeliveryRemainRecoverable() {
+ val state = mutableStateOf(PendingSendState.REJECTED)
+ var retried: String? = null
+ compose.setContent {
+ PendingSendPanel(
+ listOf(row(state.value)),
+ mainTurnBusy = false,
+ onSendNow = { retried = it },
+ )
+ }
+ compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_rejected)).assertIsDisplayed()
+ compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).performClick()
+ compose.runOnIdle {
+ assertEquals("receipt", retried)
+ state.value = PendingSendState.UNKNOWN
+ }
+ compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_unknown)).assertIsDisplayed()
+ compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertIsDisplayed()
+ }
+
+ @Test
+ fun busyPanelWarnsAndLabelsEveryRecoverableActionAsStopAndSend() {
+ val state = mutableStateOf(PendingSendState.QUEUED)
+ compose.setContent {
+ PendingSendPanel(
+ sends = listOf(row(state.value)),
+ mainTurnBusy = true,
+ onSendNow = {},
+ )
+ }
+
+ listOf(
+ PendingSendState.QUEUED,
+ PendingSendState.PARKED,
+ PendingSendState.REJECTED,
+ PendingSendState.UNKNOWN,
+ ).forEach { pendingState ->
+ compose.runOnIdle { state.value = pendingState }
+ compose
+ .onNodeWithText(
+ compose.activity.getString(R.string.chat_pending_send_now_warning),
+ ).assertIsDisplayed()
+ compose.onNodeWithText(compose.activity.getString(R.string.chat_busy_stop_and_send)).assertIsDisplayed()
+ compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).assertDoesNotExist()
+ compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_again)).assertDoesNotExist()
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt
index cd0082c8..06ac9727 100644
--- a/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt
@@ -1,14 +1,24 @@
package com.m57.hermescontrol.ui.settings.components
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.m57.hermescontrol.R
+import com.m57.hermescontrol.data.model.BusySendMode
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
@@ -20,6 +30,25 @@ class ChatSectionTest {
@get:Rule
val composeTestRule = createComposeRule()
+ @Test
+ fun busySendDefaultDropdownRoutesSelection() {
+ var chosen: BusySendMode? = null
+ composeTestRule.setContent {
+ ChatSection(
+ typingEffectEnabled = false,
+ onTypingEffectEnabledChange = {},
+ typingEffectDelayMs = 30,
+ onTypingEffectDelayMsChange = {},
+ busySendMode = BusySendMode.CORRECT,
+ onBusySendModeChange = { chosen = it },
+ )
+ }
+ composeTestRule.onNodeWithTag("busy_send_default").performClick()
+ val queueLabel = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.chat_busy_queue)
+ composeTestRule.onNodeWithText(queueLabel).performClick()
+ assertEquals(BusySendMode.QUEUE, chosen)
+ }
+
@Test
fun messageStatsMasterOff_keepsChildrenVisibleCheckedAndDisabled() {
composeTestRule.setContent {
@@ -48,31 +77,47 @@ class ChatSectionTest {
var assistantChanges = 0
var tpsChanges = 0
composeTestRule.setContent {
- ChatSection(
- typingEffectEnabled = false,
- onTypingEffectEnabledChange = {},
- typingEffectDelayMs = 30,
- onTypingEffectDelayMsChange = {},
- messageStatsEnabled = true,
- onMessageStatsEnabledChange = { masterChanges++ },
- showUserMessageTokens = true,
- onUserMessageTokensChange = { userChanges++ },
- showAssistantMessageTokens = true,
- onAssistantMessageTokensChange = { assistantChanges++ },
- showTokensPerSecond = true,
- onTokensPerSecondChange = { tpsChanges++ },
- )
+ // PR #1254: match the real settings page's scrollable viewport.
+ Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
+ ChatSection(
+ typingEffectEnabled = false,
+ onTypingEffectEnabledChange = {},
+ typingEffectDelayMs = 30,
+ onTypingEffectDelayMsChange = {},
+ messageStatsEnabled = true,
+ onMessageStatsEnabledChange = { masterChanges++ },
+ showUserMessageTokens = true,
+ onUserMessageTokensChange = { userChanges++ },
+ showAssistantMessageTokens = true,
+ onAssistantMessageTokensChange = { assistantChanges++ },
+ showTokensPerSecond = true,
+ onTokensPerSecondChange = { tpsChanges++ },
+ )
+ }
}
- composeTestRule.onNodeWithTag("settings_user_message_tokens").assertIsEnabled().performClick()
- composeTestRule.onNodeWithTag("settings_assistant_message_tokens").assertIsEnabled().performClick()
- composeTestRule.onNodeWithTag("settings_tps").assertIsEnabled().performClick()
+ composeTestRule
+ .onNodeWithTag("settings_user_message_tokens")
+ .performScrollTo()
+ .assertIsEnabled()
+ .performClick()
+ composeTestRule
+ .onNodeWithTag(
+ "settings_assistant_message_tokens",
+ ).performScrollTo()
+ .assertIsEnabled()
+ .performClick()
+ composeTestRule
+ .onNodeWithTag("settings_tps")
+ .performScrollTo()
+ .assertIsEnabled()
+ .performClick()
assertEquals(0, masterChanges)
assertEquals(1, userChanges)
assertEquals(1, assistantChanges)
assertEquals(1, tpsChanges)
- composeTestRule.onNodeWithTag("settings_message_stats").performClick()
+ composeTestRule.onNodeWithTag("settings_message_stats").performScrollTo().performClick()
assertEquals(1, masterChanges)
assertEquals(1, userChanges)
assertEquals(1, assistantChanges)
diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt
index f069698d..e81a38cb 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.config
+import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
@@ -22,6 +23,7 @@ data class ServerStoreState(
val pinnedModels: List = emptyList(),
val wsAuthParam: String = "token",
val typingEffectEnabled: Boolean = true,
+ val busySendMode: BusySendMode = BusySendMode.CORRECT,
val typingEffectDelayMs: Int = 30,
val chatFontScale: Float = 1.0f,
val messageStatsEnabled: Boolean = false,
diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
index f8c0c98c..d8d0fc33 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.config.ServerUrlMigration
import com.m57.hermescontrol.data.config.resolvedBaseUrl
import com.m57.hermescontrol.data.config.resolvedHost
import com.m57.hermescontrol.data.config.resolvedPort
+import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.CookieManager
@@ -805,6 +806,12 @@ object AuthManager {
fun isTypingEffectEnabled(): Boolean = serverStore.getLatestState().typingEffectEnabled
+ fun getBusySendMode(): BusySendMode = serverStore.getLatestState().busySendMode
+
+ fun setBusySendMode(mode: BusySendMode) {
+ serverStore.update { it.copy(busySendMode = mode) }
+ }
+
fun setTypingEffectEnabled(enabled: Boolean) {
serverStore.update { it.copy(typingEffectEnabled = enabled) }
}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt
index e4f6cc96..8901202b 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt
@@ -86,4 +86,7 @@ interface ChatMessageDao {
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
suspend fun deleteMessagesForSession(sessionId: String)
+
+ @Query("DELETE FROM chat_messages WHERE id = :id AND rest_id IS NULL")
+ suspend fun deleteUnconfirmedMessage(id: String)
}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/BusySendMode.kt b/app/src/main/java/com/m57/hermescontrol/data/model/BusySendMode.kt
new file mode 100644
index 00000000..44f6163e
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/data/model/BusySendMode.kt
@@ -0,0 +1,11 @@
+package com.m57.hermescontrol.data.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+enum class BusySendMode {
+ CORRECT,
+ QUEUE,
+ GUIDE,
+ INTERRUPT,
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt
index f4e79a7f..cdbf2e51 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt
@@ -11,6 +11,7 @@ object WsMethods {
const val SESSION_CREATE = "session.create"
const val SESSION_INTERRUPT = "session.interrupt"
const val SESSION_REDIRECT = "session.redirect"
+ const val SESSION_STEER = "session.steer"
const val SESSION_DELETE = "session.delete"
const val SESSION_TITLE = "session.title"
const val SESSION_BRANCH = "session.branch"
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/BusySendModeLabel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/BusySendModeLabel.kt
new file mode 100644
index 00000000..99a4d91e
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/BusySendModeLabel.kt
@@ -0,0 +1,15 @@
+package com.m57.hermescontrol.ui.chat
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import com.m57.hermescontrol.R
+import com.m57.hermescontrol.data.model.BusySendMode
+
+@Composable
+fun BusySendMode.label(): String =
+ when (this) {
+ BusySendMode.CORRECT -> stringResource(R.string.chat_busy_correct)
+ BusySendMode.QUEUE -> stringResource(R.string.chat_busy_queue)
+ BusySendMode.GUIDE -> stringResource(R.string.chat_busy_guide)
+ BusySendMode.INTERRUPT -> stringResource(R.string.chat_busy_interrupt)
+ }
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt
index da1b4537..89c5e210 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt
@@ -84,4 +84,8 @@ open class ChatPersistenceRepository(
suspend fun clearMessagesForSession(sessionId: String) {
daoProvider().deleteMessagesForSession(sessionId)
}
+
+ suspend fun deleteMessage(id: String) {
+ daoProvider().deleteUnconfirmedMessage(id)
+ }
}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt
new file mode 100644
index 00000000..c95b505d
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt
@@ -0,0 +1,17 @@
+package com.m57.hermescontrol.ui.chat
+
+/** Presentation only: retain queued messages internally for persistence and receipt matching. */
+internal fun messagesWithoutUnsentQueue(
+ messages: List,
+ pendingSends: List,
+): List {
+ val waitingIds =
+ pendingSends
+ .filter { it.state == PendingSendState.QUEUED || it.state == PendingSendState.PARKED }
+ .map { it.id }
+ .toSet()
+ if (waitingIds.isEmpty()) return messages
+ return messages.filterNot {
+ it.role == MessageRole.USER && it.id in waitingIds && it.canonicalRestId == null
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt
index 78850553..3dec359f 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt
@@ -86,6 +86,7 @@ import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
+import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.reasoningSupport
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
@@ -172,7 +173,11 @@ fun ChatScreen(
// Snapshot-backed search state — read directly so only the scopes that
// read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState
- val displayedMessages = timelineState.historyMessages ?: state.messages
+ val sourceMessages = timelineState.historyMessages ?: state.messages
+ val displayedMessages =
+ remember(sourceMessages, state.pendingSends) {
+ messagesWithoutUnsentQueue(sourceMessages, state.pendingSends)
+ }
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
@@ -195,6 +200,7 @@ fun ChatScreen(
}
Lifecycle.Event.ON_RESUME -> {
+ viewModel.refreshSettings()
connectorsViewModel.onResume()
val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted
val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted }
@@ -314,7 +320,7 @@ fun ChatScreen(
// streaming tail.
LaunchedEffect(
timelineState.isHistorical,
- state.messages,
+ displayedMessages,
streamingState.streamingMessage,
streamingState.isThinking,
state.subagentIndicators,
@@ -328,13 +334,13 @@ fun ChatScreen(
scrollController.onTailChanged(
tailKey =
tailContentKey(
- messages = state.messages,
+ messages = displayedMessages,
streamingMessage = streamingState.streamingMessage,
isThinking = streamingState.isThinking,
subagentIndicators = state.subagentIndicators,
clarifyRequest = state.clarifyRequest,
),
- messageCount = state.messages.size,
+ messageCount = displayedMessages.size,
)
}
LaunchedEffect(timelineState.historyAnchorRowId) {
@@ -872,6 +878,12 @@ fun ChatScreen(
},
)
+ com.m57.hermescontrol.ui.chat.components.PendingSendPanel(
+ sends = state.pendingSends,
+ mainTurnBusy = state.isMainTurnBusy,
+ onSendNow = viewModel::sendQueuedNow,
+ )
+
ChatInputBar(
inputFieldValue = inputFieldValue,
onInputChange = { inputFieldValue = it },
@@ -882,9 +894,16 @@ fun ChatScreen(
scrollController.jumpToBottom(animated = true)
}
},
+ onBusySend = { mode ->
+ if (viewModel.sendMessage(inputFieldValue.text, mode)) {
+ inputFieldValue = TextFieldValue("")
+ scrollController.jumpToBottom(animated = true)
+ }
+ },
onMicTap = mediaLaunchers.onMicTap,
isListening = mediaLaunchers.isListening,
isAgentTyping = state.isAgentTyping,
+ isMainTurnBusy = state.isMainTurnBusy,
isConnected = state.isConnected,
isSessionReady = state.isSessionReady && !timelineState.isHistorical,
sessionPreparationFailed = state.resumeError != null,
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt
new file mode 100644
index 00000000..a4a9263e
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt
@@ -0,0 +1,149 @@
+package com.m57.hermescontrol.ui.chat
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.m57.hermescontrol.data.model.Attachment
+import com.m57.hermescontrol.data.model.BusySendMode
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+/** The receipt stays on device until REST confirms a user row; a gateway queue is only volatile. */
+@Serializable
+data class PendingSend(
+ val id: String,
+ val scope: String,
+ val sessionId: String,
+ val text: String,
+ val attachments: List = emptyList(),
+ val mode: BusySendMode,
+ val state: PendingSendState = PendingSendState.QUEUED,
+ val createdAt: Long = System.currentTimeMillis(),
+ val attempts: Int = 0,
+ /** Legacy source URIs are retained for an explicit user retry, never auto-dispatched. */
+ val requiresAttachmentRecovery: Boolean = false,
+)
+
+@Serializable
+enum class PendingSendState {
+ QUEUED,
+ PARKED,
+ SENDING,
+ ACCEPTED,
+ UNKNOWN,
+ REJECTED,
+}
+
+/**
+ * Reconcile only identities that the transcript merge already mapped to a durable REST row.
+ * Content matching belongs to [matchTranscriptMessages], which normalizes gateway wrappers and
+ * consumes duplicate occurrences once; repeating that matching against receipts can acknowledge
+ * more sends than the history page actually contains.
+ */
+internal fun pendingSendIdsConfirmedByDurableAliases(
+ confirmedAliases: List,
+ pending: List,
+): Set {
+ val durableUserAliasIds =
+ confirmedAliases
+ .asSequence()
+ .filter { it.role == MessageRole.USER && it.canonicalRestId != null }
+ .map { it.id }
+ .toSet()
+ return pending
+ .asSequence()
+ .filter {
+ it.state in
+ setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
+ }.map { it.id }
+ .filter { it in durableUserAliasIds }
+ .toSet()
+}
+
+/** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */
+class ChatSendStore(
+ private val prefs: SharedPreferences? = null,
+) {
+ constructor(context: Context) : this(
+ context.applicationContext.getSharedPreferences("chat_send_outbox", Context.MODE_PRIVATE),
+ )
+
+ private val json = Json { ignoreUnknownKeys = true }
+ private var rows: List =
+ prefs?.getString("rows", null)?.let { raw ->
+ runCatching { json.decodeFromString>(raw) }.getOrDefault(emptyList())
+ } ?: emptyList()
+
+ init {
+ // A request may have reached the gateway before process death. Never replay it automatically.
+ replace(
+ rows.map {
+ it.quarantineLegacyAttachments().let { quarantined ->
+ if (quarantined.state == PendingSendState.SENDING ||
+ quarantined.state == PendingSendState.ACCEPTED
+ ) {
+ quarantined.copy(state = PendingSendState.UNKNOWN)
+ } else {
+ quarantined
+ }
+ }
+ },
+ )
+ }
+
+ private fun PendingSend.quarantineLegacyAttachments(): PendingSend =
+ if (attachments.any { !it.uri.startsWith("file:", ignoreCase = true) }) {
+ copy(state = PendingSendState.REJECTED, requiresAttachmentRecovery = true)
+ } else {
+ this
+ }
+
+ @Synchronized
+ fun all(): List = rows
+
+ @Synchronized
+ fun put(row: PendingSend) {
+ replace(rows.filterNot { it.id == row.id } + row.quarantineLegacyAttachments())
+ }
+
+ @Synchronized
+ fun remove(id: String) {
+ replace(rows.filterNot { it.id == id })
+ }
+
+ @Synchronized
+ fun update(
+ id: String,
+ transform: (PendingSend) -> PendingSend,
+ ) {
+ replace(rows.map { if (it.id == id) transform(it) else it })
+ }
+
+ @Synchronized
+ fun promote(id: String) {
+ val row = rows.firstOrNull { it.id == id } ?: return
+ replace(listOf(row.copy(state = PendingSendState.QUEUED)) + rows.filterNot { it.id == id })
+ }
+
+ @Synchronized
+ fun park(
+ scope: String,
+ sessionId: String,
+ ) {
+ replace(
+ rows.map {
+ if (it.scope == scope && it.sessionId == sessionId && it.state == PendingSendState.QUEUED) {
+ it.copy(state = PendingSendState.PARKED)
+ } else {
+ it
+ }
+ },
+ )
+ }
+
+ private fun replace(next: List) {
+ if (prefs != null && !prefs.edit().putString("rows", json.encodeToString(next)).commit()) {
+ error("Could not save chat send queue")
+ }
+ rows = next
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
index dfdabd63..05f5101a 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
@@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.local.SlashUsageStore
import com.m57.hermescontrol.data.model.Attachment
+import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.ModelCapabilities
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
@@ -28,6 +29,7 @@ import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.data.ws.ConnectionOperationParser
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
+import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toAny
@@ -106,6 +108,10 @@ private data class PreparedAttachment(
val encodedFile: File,
)
+private class QueuedAttachmentTooLargeException(
+ val attachment: Attachment,
+) : Exception("Attachment too large: ${attachment.name}")
+
private sealed interface PrepareAttachmentResult {
data class Success(
val prepared: PreparedAttachment,
@@ -123,6 +129,10 @@ data class ChatUiState(
val chatTitle: String = "Hermes",
val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
val isAgentTyping: Boolean = false,
+ val isMainTurnBusy: Boolean = false,
+ val isSending: Boolean = false,
+ val busySendMode: BusySendMode = BusySendMode.CORRECT,
+ val pendingSends: List = emptyList(),
val isThinking: Boolean = false,
val thinkingText: String = "",
val isLoading: Boolean = false,
@@ -403,6 +413,7 @@ class ChatViewModel(
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
private val historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = searchDispatcher,
+ private val sendStore: ChatSendStore = ChatSendStore(application),
) : AndroidViewModel(application) {
constructor(application: Application) : this(application, startCleanup = true)
@@ -427,8 +438,53 @@ class ChatViewModel(
val connectionCheckpoint: ConnectionResumeCheckpoint? = null,
)
+ private data class SendOwner(
+ val scope: String,
+ val storageSessionId: String,
+ val agentSessionId: String,
+ val generation: Long,
+ )
+
+ private data class HardInterruptRequest(
+ val messageId: String,
+ val mainTurnEpoch: Long,
+ val owner: SendOwner,
+ )
+
+ private data class PendingSendReservation(
+ val pending: PendingSend,
+ val userMessage: ChatMessage,
+ val wasStreaming: Boolean,
+ val storageSessionId: String,
+ val agentSessionId: String,
+ val scope: String,
+ val generation: Long,
+ val mainTurnEpoch: Long,
+ val originalPending: PendingSend? = null,
+ )
+
private val sessionRequestById = ConcurrentHashMap()
+ private val outgoingRequestById = ConcurrentHashMap()
+ private val hardInterruptRequestById = ConcurrentHashMap()
+ private val queuedStagingIds = ConcurrentHashMap.newKeySet()
+ private val pendingSendReservationLock = Any()
+ private val pendingSendReservations = mutableListOf()
+ private var pendingSendReservationJob: Job? = null
+ private var queueDrainJob: Job? = null
+ private var mainTurnBusy = false
+ set(busy) {
+ field = busy
+ _uiState.update { state ->
+ if (state.isMainTurnBusy == busy) state else state.copy(isMainTurnBusy = busy)
+ }
+ }
+
+ private var lastMainCompletionAt = 0L
+ private var lastSubmissionKey: String? = null
+ private var lastSubmissionAt = 0L
private var sessionGeneration = 0L
+ private var mainTurnEpoch = 0L
+ private var lastPendingSendCreatedAt = 0L
private var resumeRequestSequence = 0L
private var activeResumeRequestSequence = 0L
@@ -446,6 +502,8 @@ class ChatViewModel(
val attachments: List,
val wasStreaming: Boolean,
val userMessage: ChatMessage,
+ val createdAt: Long,
+ val mainTurnEpoch: Long,
)
private var pendingInitialPrompt: PendingPrompt? = null
@@ -680,6 +738,27 @@ class ChatViewModel(
status == ConnectionStatus.NO_NETWORK ||
status == ConnectionStatus.AUTH_EXPIRED
) {
+ mainTurnBusy = false
+ // A gateway acceptance is not a durable REST acknowledgement. Recover even
+ // receipts whose RPC correlation was already removed, without resending them.
+ val acceptedIds =
+ sendStore
+ .all()
+ .filter {
+ it.scope == sendScope() && it.state == PendingSendState.ACCEPTED
+ }.map { it.id }
+ (outgoingRequestById.values + hardInterruptRequestById.values.map { it.messageId } + acceptedIds)
+ .distinct()
+ .forEach { messageId ->
+ if (sendStore.all().any {
+ it.id == messageId &&
+ (it.state == PendingSendState.SENDING || it.state == PendingSendState.ACCEPTED)
+ }
+ ) {
+ markPendingSend(messageId, PendingSendState.UNKNOWN)
+ removeUnconfirmedBubble(messageId)
+ }
+ }
_uiState.update { it.copy(isLoading = false) }
subagentsDelegate.closeSubagentTranscript()
// The runtime session id is only valid while the socket
@@ -695,6 +774,8 @@ class ChatViewModel(
activeHydrationRequestSequence = ++hydrationRequestSequence
// Preserve metadata so late results and errors are rejected as stale.
sessionGeneration++
+ queueDrainJob?.cancel()
+ queueDrainJob = null
// #129: invalidate and cancel paged work together, including its busy flags.
hydrationJob?.cancel()
olderJob?.cancel()
@@ -867,6 +948,8 @@ class ChatViewModel(
// session errors here so the shared reducer cannot clear loading or
// surface an error for a newly selected session.
if (event is WsEvent.RpcError && isStaleSessionRequest(event.id)) {
+ outgoingRequestById.remove(event.id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) }
+ hardInterruptRequestById.remove(event.id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) }
forgetRequest(event.id)
return
}
@@ -930,18 +1013,25 @@ class ChatViewModel(
}
is WsEvent.MessageToken -> {
+ if (isCurrentSession(event.sessionId)) mainTurnBusy = true
streamingController.handleMessageToken(event)
}
is WsEvent.ThinkingDelta -> {
+ if (isCurrentSession(event.sessionId)) mainTurnBusy = true
streamingController.handleThinkingDelta(event)
}
is WsEvent.ReasoningDelta -> {
+ if (isCurrentSession(event.sessionId)) mainTurnBusy = true
streamingController.handleReasoningDelta(event)
}
is WsEvent.MessageStart -> {
+ if (isCurrentSession(event.sessionId)) {
+ mainTurnEpoch++
+ mainTurnBusy = true
+ }
// The server accepted a prompt for this session — its DB row
// now exists (created lazily at prompt.submit), so a reconnect
// resume will succeed.
@@ -950,15 +1040,26 @@ class ChatViewModel(
}
is WsEvent.MessageComplete -> {
+ if (isCurrentSession(event.sessionId)) {
+ mainTurnBusy = false
+ lastMainCompletionAt = System.currentTimeMillis()
+ refreshSendReceipts()
+ }
// Buffers cleared before reduce; ViewModel resets them after
streamingController.resetStreaming()
}
is WsEvent.MessageDone -> {
+ if (isCurrentSession(event.sessionId)) {
+ mainTurnBusy = false
+ lastMainCompletionAt = System.currentTimeMillis()
+ refreshSendReceipts()
+ }
streamingController.resetStreaming()
}
is WsEvent.ToolStart -> {
+ if (isCurrentSession(event.sessionId)) mainTurnBusy = true
// Issue #771: the reducer keeps the streaming message (and its
// reasoning) alive across the tool call so the finalized answer
// retains the thinking card. Only the token buffers are cleared
@@ -1327,6 +1428,7 @@ class ChatViewModel(
// Session info pushed by backend when config changes
// (model switch, reasoning level, etc.)
if (info != null) {
+ (info["running"] as? Boolean)?.let { mainTurnBusy = it }
val model = info["model"] as? String
val provider = info["provider"] as? String
val reasoningEffort = info["reasoning_effort"] as? String
@@ -1416,6 +1518,46 @@ class ChatViewModel(
// ── RPC response handling ────────────────────────────────────────────
+ private fun settleOutgoingResult(
+ requestId: String,
+ method: String,
+ result: Any?,
+ ) {
+ val messageId = outgoingRequestById.remove(requestId) ?: return
+ val status = rpcResultMap(result)?.get("status") as? String
+ val accepted =
+ when (method) {
+ WsMethods.SESSION_REDIRECT -> status == "redirected" || status == "queued"
+ WsMethods.SESSION_STEER -> status == "queued" || status == "steered"
+ else -> status == "streaming" || status == "queued"
+ }
+ when {
+ accepted -> {
+ markPendingSend(messageId, PendingSendState.ACCEPTED)
+ if (sendStore.all().any { it.id == messageId && lastMainCompletionAt >= it.createdAt }) {
+ refreshSendReceipts()
+ }
+ }
+
+ status == "rejected" && method != WsMethods.PROMPT_SUBMIT -> {
+ removeUnconfirmedBubble(messageId)
+ sendStore.update(messageId) { it.copy(mode = BusySendMode.QUEUE, state = PendingSendState.QUEUED) }
+ publishPendingSends()
+ drainPendingQueue()
+ }
+
+ status == "rejected" -> {
+ markPendingSend(messageId, PendingSendState.REJECTED)
+ removeUnconfirmedBubble(messageId)
+ }
+
+ else -> {
+ markPendingSend(messageId, PendingSendState.UNKNOWN)
+ removeUnconfirmedBubble(messageId)
+ }
+ }
+ }
+
@Suppress("UNCHECKED_CAST")
private fun handleRpcResult(
id: String,
@@ -1423,7 +1565,16 @@ class ChatViewModel(
) {
val method = idToMethod.remove(id) ?: return
val request = sessionRequestById.remove(id)
- if (request != null && isStaleSessionRequest(request)) return
+ if (request != null && isStaleSessionRequest(request)) {
+ outgoingRequestById.remove(id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) }
+ hardInterruptRequestById.remove(id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) }
+ return
+ }
+ if (method == WsMethods.PROMPT_SUBMIT || method == WsMethods.SESSION_REDIRECT ||
+ method == WsMethods.SESSION_STEER
+ ) {
+ settleOutgoingResult(id, method, result)
+ }
when (method) {
WsMethods.SESSION_CREATE -> {
val resultMap = result as? Map
@@ -1455,6 +1606,7 @@ class ChatViewModel(
sessionUsage = null,
)
}
+ publishPendingSends()
// A gone-session recovery just landed — announce it now that
// the message list has been reset by the create.
if (pendingGoneSessionNotice) {
@@ -1474,13 +1626,27 @@ class ChatViewModel(
val pending = pendingInitialPrompt
pendingInitialPrompt = null
if (pending != null) {
- dispatchPrompt(
- text = pending.text,
- attachments = pending.attachments,
- wasStreaming = pending.wasStreaming,
- storageSessionId = storageId,
- agentSessionId = runtimeId,
- userMessage = pending.userMessage,
+ enqueuePendingSendReservation(
+ PendingSendReservation(
+ pending =
+ PendingSend(
+ id = pending.userMessage.id,
+ scope = sendScope(),
+ sessionId = storageId,
+ text = pending.text,
+ attachments = pending.attachments,
+ mode = BusySendMode.CORRECT,
+ state = PendingSendState.SENDING,
+ createdAt = pending.createdAt,
+ ),
+ userMessage = pending.userMessage,
+ wasStreaming = pending.wasStreaming,
+ storageSessionId = storageId,
+ agentSessionId = runtimeId,
+ scope = sendScope(),
+ generation = sessionGeneration,
+ mainTurnEpoch = pending.mainTurnEpoch,
+ ),
)
}
}
@@ -1549,6 +1715,7 @@ class ChatViewModel(
}
runtimeSessionId = runtimeId
connectionOperationDelegate.bindSession(runtimeId)
+ mainTurnBusy = resultMap["running"] as? Boolean ?: false
// Resume succeeded — the gateway confirmed the DB row.
sessionHasServerPresence = true
@@ -1623,6 +1790,7 @@ class ChatViewModel(
val generation = resumeRequest.generation
resumedGeneration = generation
finishResumeWhenHydrated(generation)
+ publishPendingSends()
subagentsDelegate.hydrateSubagents(runtimeSessionId ?: sessionId)
// Reconnect replay: resume payload can carry `pending_approval`
// (server `_session_info_payload`); surface it, then ask for
@@ -1649,20 +1817,70 @@ class ChatViewModel(
}
WsMethods.SESSION_INTERRUPT -> {
- // Issue #842 follow-up: seal whatever the agent streamed so far
- // (interim commentary + partial answer) BEFORE clearing the
- // streaming state. The old tool.start orphan seal used to leave
- // pre-tool text behind on interrupt; with that seal gone, the
- // partial would otherwise vanish entirely.
- sealStreamingMessageIfAny()
- _uiState.update {
- it.copy(
- isAgentTyping = false,
- )
+ val hardInterrupt = hardInterruptRequestById.remove(id)
+ val status = rpcResultMap(result)?.get("status")
+ val targetsCurrentTurn = hardInterrupt == null || hardInterrupt.mainTurnEpoch == mainTurnEpoch
+ if (targetsCurrentTurn && status in setOf("interrupted", "not_interrupted")) {
+ // Never let a late result for an older same-session turn
+ // erase a newer stream or make its replacement race it.
+ sealStreamingMessageIfAny()
+ _uiState.update { it.copy(isAgentTyping = false) }
+ _streamingState.update { StreamingState() }
+ streamingController.resetStreaming()
+ if (status == "interrupted") addSystemMessage("Session interrupted")
+ mainTurnBusy = false
+ }
+ hardInterrupt?.let { tracked ->
+ val messageId = tracked.messageId
+ val row = sendStore.all().firstOrNull { it.id == messageId } ?: return@let
+ when {
+ !targetsCurrentTurn && status in setOf("interrupted", "not_interrupted") -> {
+ // The replacement is known unsent, but this successful interrupt
+ // result belongs to an older turn. Wait behind the newer turn.
+ sendStore.update(messageId) {
+ it.copy(state = PendingSendState.QUEUED, mode = BusySendMode.QUEUE)
+ }
+ publishPendingSends()
+ }
+
+ status == "interrupted" -> {
+ val message =
+ _uiState.value.messages.firstOrNull { it.id == row.id }
+ ?: ChatMessage(
+ id = row.id,
+ role = MessageRole.USER,
+ content = row.text,
+ attachments = row.attachments,
+ )
+ dispatchPrompt(
+ text = row.text,
+ attachments = row.attachments,
+ wasStreaming = false,
+ storageSessionId = row.sessionId,
+ agentSessionId = tracked.owner.agentSessionId,
+ userMessage = message,
+ mode = BusySendMode.INTERRUPT,
+ queued = true,
+ owner = tracked.owner,
+ pendingReceipt = row,
+ outboundAlreadyStarted = true,
+ )
+ }
+
+ status == "not_interrupted" -> {
+ sendStore.update(messageId) {
+ it.copy(state = PendingSendState.QUEUED, mode = BusySendMode.QUEUE)
+ }
+ publishPendingSends()
+ drainPendingQueue()
+ }
+
+ else -> {
+ markPendingSend(messageId, PendingSendState.UNKNOWN)
+ removeUnconfirmedBubble(messageId)
+ }
+ }
}
- _streamingState.update { StreamingState() }
- streamingController.resetStreaming()
- addSystemMessage("Session interrupted")
}
WsMethods.COMMANDS_CATALOG -> {
@@ -1759,19 +1977,45 @@ class ChatViewModel(
) {
val method = idToMethod.remove(id) ?: return
val request = sessionRequestById.remove(id)
- if (request != null && isStaleSessionRequest(request)) return
+ if (request != null && isStaleSessionRequest(request)) {
+ outgoingRequestById.remove(id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) }
+ hardInterruptRequestById.remove(id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) }
+ return
+ }
val errorMsg =
when (error) {
is Map<*, *> -> error["message"] as? String ?: error.toString()
else -> error.toString()
}
- if (method == WsMethods.PROMPT_SUBMIT || method == WsMethods.SESSION_REDIRECT) {
+ val messageId = outgoingRequestById.remove(id)
+ if (messageId != null) {
+ val code = (error as? JsonRpcError)?.code
+ val correction = method == WsMethods.SESSION_REDIRECT || method == WsMethods.SESSION_STEER
+ if (correction && code in setOf(4001, 4009, 4010)) {
+ removeUnconfirmedBubble(messageId)
+ sendStore.update(messageId) { it.copy(mode = BusySendMode.QUEUE, state = PendingSendState.QUEUED) }
+ publishPendingSends()
+ drainPendingQueue()
+ } else {
+ markPendingSend(
+ messageId,
+ if (code != null && code in 4000..4999) PendingSendState.REJECTED else PendingSendState.UNKNOWN,
+ )
+ removeUnconfirmedBubble(messageId)
+ }
+ }
+ hardInterruptRequestById.remove(id)?.let { tracked ->
+ markPendingSend(tracked.messageId, PendingSendState.UNKNOWN)
+ removeUnconfirmedBubble(tracked.messageId)
+ }
+
+ if (method == WsMethods.PROMPT_SUBMIT && !mainTurnBusy) {
// A prompt rejection can arrive before message.start, leaving the
// UI with only the optimistic typing state. Clear the live tail so
// a failed generation cannot leave stale dots or reasoning behind.
sealStreamingMessageIfAny()
- _uiState.update { it.copy(isAgentTyping = false) }
+ _uiState.update { it.copy(isAgentTyping = mainTurnBusy) }
_streamingState.update { StreamingState() }
streamingController.resetStreaming()
}
@@ -1798,9 +2042,13 @@ class ChatViewModel(
if (method == WsMethods.SESSION_CREATE) {
val pending = pendingInitialPrompt
pendingInitialPrompt = null
+ publishPendingSends()
if (pending != null) {
_uiState.update {
it.copy(
+ messages = it.messages.filterNot { message -> message.id == pending.userMessage.id },
+ pendingAttachments = pending.attachments + it.pendingAttachments,
+ composerTextToRestore = pending.text,
isAgentTyping = false,
errorMessage = "Failed to create session: $errorMsg",
)
@@ -1822,6 +2070,566 @@ class ChatViewModel(
// ── Send message ─────────────────────────────────────────────────────
+ private fun sendScope(): String =
+ listOf(
+ AuthManager.getBaseUrl(),
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+
+ private fun isCurrentSendContext(
+ scope: String,
+ storageSessionId: String,
+ agentSessionId: String,
+ generation: Long,
+ ): Boolean =
+ generation == sessionGeneration &&
+ scope == sendScope() &&
+ storageSessionId == _uiState.value.currentSessionId &&
+ agentSessionId == runtimeSessionId
+
+ private fun isCurrentSendContext(owner: SendOwner): Boolean =
+ isCurrentSendContext(
+ owner.scope,
+ owner.storageSessionId,
+ owner.agentSessionId,
+ owner.generation,
+ )
+
+ private fun captureSendOwner(
+ storageSessionId: String,
+ agentSessionId: String,
+ scope: String = sendScope(),
+ generation: Long = sessionGeneration,
+ ): SendOwner = SendOwner(scope, storageSessionId, agentSessionId, generation)
+
+ /** Context invalidation and synchronous WS enqueue are serialized on Main. */
+ private suspend fun withCurrentSendOwner(
+ owner: SendOwner,
+ action: () -> T,
+ ): T? =
+ withContext(Dispatchers.Main.immediate) {
+ if (isCurrentSendContext(owner)) action() else null
+ }
+
+ private fun restoreKnownUnsentReceipt(
+ attempted: PendingSend,
+ rollback: PendingSend?,
+ deleteSnapshot: Boolean,
+ ) {
+ if (rollback != null) sendStore.put(rollback) else sendStore.remove(attempted.id)
+ publishPendingSends()
+ if (deleteSnapshot && rollback?.attachments.orEmpty().none { it.uri.startsWith("file:", ignoreCase = true) }) {
+ viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(attempted.id) }
+ }
+ }
+
+ /** Commit/claim and enqueue the first RPC without releasing Main ownership. */
+ private suspend fun commitReceiptAndEnqueue(
+ owner: SendOwner,
+ attempted: PendingSend,
+ rollback: PendingSend?,
+ deleteSnapshotOnRollback: Boolean,
+ enqueue: () -> T,
+ ): T? =
+ withContext(Dispatchers.Main.immediate) {
+ if (!isCurrentSendContext(owner)) {
+ restoreKnownUnsentReceipt(attempted, rollback, deleteSnapshotOnRollback)
+ return@withContext null
+ }
+ sendStore.put(attempted)
+ publishPendingSends()
+ // Store callbacks can re-enter profile selection.
+ if (!isCurrentSendContext(owner)) {
+ restoreKnownUnsentReceipt(attempted, rollback, deleteSnapshotOnRollback)
+ return@withContext null
+ }
+ enqueue()
+ }
+
+ private fun deleteQueuedAttachmentSnapshot(messageId: String) {
+ File(getApplication().filesDir, "chat-send/$messageId").deleteRecursively()
+ }
+
+ @Synchronized
+ private fun nextPendingSendCreatedAt(): Long {
+ lastPendingSendCreatedAt = maxOf(System.currentTimeMillis(), lastPendingSendCreatedAt + 1L)
+ return lastPendingSendCreatedAt
+ }
+
+ private fun hasPendingSendReservation(
+ scope: String,
+ sessionId: String?,
+ ): Boolean =
+ synchronized(pendingSendReservationLock) {
+ pendingSendReservations.any { it.scope == scope && it.storageSessionId == sessionId }
+ }
+
+ private fun enqueuePendingSendReservation(reservation: PendingSendReservation) {
+ synchronized(pendingSendReservationLock) { pendingSendReservations += reservation }
+ startPendingSendReservationDrain()
+ }
+
+ @Synchronized
+ private fun startPendingSendReservationDrain() {
+ if (pendingSendReservationJob?.isActive == true) return
+ pendingSendReservationJob =
+ viewModelScope.launch(ioDispatcher) {
+ try {
+ while (true) {
+ val reservation =
+ synchronized(pendingSendReservationLock) { pendingSendReservations.firstOrNull() }
+ ?: break
+ try {
+ processPendingSendReservation(reservation)
+ } finally {
+ synchronized(pendingSendReservationLock) {
+ pendingSendReservations.removeAll { it.pending.id == reservation.pending.id }
+ }
+ }
+ }
+ } finally {
+ pendingSendReservationJob = null
+ if (synchronized(pendingSendReservationLock) { pendingSendReservations.isNotEmpty() }) {
+ startPendingSendReservationDrain()
+ } else {
+ drainPendingQueue()
+ }
+ }
+ }
+ }
+
+ private suspend fun processPendingSendReservation(reservation: PendingSendReservation) {
+ val owner =
+ captureSendOwner(
+ reservation.storageSessionId,
+ reservation.agentSessionId,
+ reservation.scope,
+ reservation.generation,
+ )
+ try {
+ var staged = stageQueuedAttachments(reservation.pending).copy(requiresAttachmentRecovery = false)
+ kotlinx.coroutines.yield()
+ if (withCurrentSendOwner(owner) { true } != true) {
+ if (reservation.originalPending == null) deleteQueuedAttachmentSnapshot(reservation.pending.id)
+ return
+ }
+ if (reservation.wasStreaming && staged.mode == BusySendMode.INTERRUPT &&
+ reservation.mainTurnEpoch != mainTurnEpoch
+ ) {
+ staged = staged.copy(mode = BusySendMode.QUEUE, state = PendingSendState.QUEUED)
+ }
+ val stagedMessage =
+ reservation.userMessage.copy(
+ attachments = staged.attachments.takeIf { it.isNotEmpty() },
+ )
+ if (withCurrentSendOwner(owner) {
+ _uiState.update { state ->
+ val messages =
+ if (state.messages.any { it.id == stagedMessage.id }) {
+ state.messages.map { if (it.id == stagedMessage.id) stagedMessage else it }
+ } else {
+ state.messages + stagedMessage
+ }
+ state.copy(messages = messages, isSending = true)
+ }
+ true
+ } != true
+ ) {
+ if (reservation.originalPending == null) deleteQueuedAttachmentSnapshot(staged.id)
+ return
+ }
+
+ when {
+ staged.state == PendingSendState.QUEUED -> {
+ val committed =
+ withContext(Dispatchers.Main.immediate) {
+ if (!isCurrentSendContext(owner)) return@withContext false
+ sendStore.put(staged)
+ publishPendingSends()
+ if (!isCurrentSendContext(owner)) {
+ restoreKnownUnsentReceipt(
+ staged,
+ reservation.originalPending,
+ deleteSnapshot = reservation.originalPending != null,
+ )
+ false
+ } else {
+ true
+ }
+ }
+ if (committed) repo.persistMessage(stagedMessage, owner.storageSessionId)
+ }
+
+ reservation.wasStreaming && staged.mode == BusySendMode.INTERRUPT -> {
+ commitReceiptAndEnqueue(
+ owner,
+ staged,
+ reservation.originalPending,
+ deleteSnapshotOnRollback = true,
+ ) {
+ wsClient.send(
+ WsMethods.SESSION_INTERRUPT,
+ mapOf("session_id" to owner.agentSessionId),
+ onSent = { id -> trackHardInterrupt(id, staged.id, owner, reservation.mainTurnEpoch) },
+ )
+ }
+ }
+
+ else -> {
+ dispatchPrompt(
+ text = staged.text,
+ attachments = staged.attachments,
+ wasStreaming = reservation.wasStreaming,
+ storageSessionId = owner.storageSessionId,
+ agentSessionId = owner.agentSessionId,
+ userMessage = stagedMessage,
+ mode = if (reservation.wasStreaming) staged.mode else BusySendMode.CORRECT,
+ owner = owner,
+ pendingReceipt = staged,
+ rollbackReceipt = reservation.originalPending,
+ deleteSnapshotOnRollback = true,
+ ).join()
+ }
+ }
+ } catch (e: Exception) {
+ if (reservation.originalPending != null) {
+ sendStore.put(reservation.originalPending)
+ } else {
+ sendStore.remove(reservation.pending.id)
+ deleteQueuedAttachmentSnapshot(reservation.pending.id)
+ }
+ publishPendingSends()
+ if (e is CancellationException) throw e
+ if (withCurrentSendOwner(owner) { true } == true) {
+ if (reservation.originalPending == null) {
+ _uiState.update { state ->
+ state.copy(
+ messages = state.messages.filterNot { it.id == reservation.pending.id },
+ pendingAttachments = reservation.pending.attachments + state.pendingAttachments,
+ composerTextToRestore = reservation.pending.text,
+ errorMessage =
+ if (e is QueuedAttachmentTooLargeException) {
+ attachmentTooLargeMessage(e.attachment)
+ } else {
+ "Could not retain attachment"
+ },
+ )
+ }
+ } else if (e is QueuedAttachmentTooLargeException) {
+ _uiState.update { it.copy(errorMessage = attachmentTooLargeMessage(e.attachment)) }
+ }
+ publishPendingSends()
+ }
+ }
+ }
+
+ private fun publishPendingSends() {
+ val sessionId = _uiState.value.currentSessionId
+ val scope = sendScope()
+ val current = sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
+ _uiState.update {
+ it.copy(
+ pendingSends = current,
+ isSending =
+ pendingInitialPrompt != null || current.any { row -> row.state == PendingSendState.SENDING },
+ )
+ }
+ }
+
+ private fun trackOutgoingRequest(
+ requestId: String,
+ messageId: String,
+ method: String,
+ generation: Long,
+ sessionId: String,
+ ) {
+ outgoingRequestById[requestId] = messageId
+ trackSessionRequest(requestId, method, generation, sessionId = sessionId)
+ if (!isTestEnvironment()) {
+ viewModelScope.launch {
+ delay(30_000L)
+ expireOutgoingRequest(requestId)
+ }
+ }
+ }
+
+ internal fun expireOutgoingRequest(requestId: String) {
+ val messageId =
+ outgoingRequestById[requestId]
+ ?: hardInterruptRequestById[requestId]?.messageId
+ ?: return
+ if (sendStore.all().any { it.id == messageId && it.state == PendingSendState.SENDING }) {
+ markPendingSend(messageId, PendingSendState.UNKNOWN)
+ removeUnconfirmedBubble(messageId)
+ }
+ }
+
+ private fun trackHardInterrupt(
+ requestId: String,
+ messageId: String,
+ owner: SendOwner,
+ interruptedTurnEpoch: Long,
+ ) {
+ hardInterruptRequestById[requestId] = HardInterruptRequest(messageId, interruptedTurnEpoch, owner)
+ trackSessionRequest(
+ requestId,
+ WsMethods.SESSION_INTERRUPT,
+ owner.generation,
+ sessionId = owner.storageSessionId,
+ )
+ if (!isTestEnvironment()) {
+ viewModelScope.launch {
+ delay(30_000L)
+ expireOutgoingRequest(requestId)
+ }
+ }
+ }
+
+ private fun markPendingSend(
+ messageId: String,
+ state: PendingSendState,
+ ) {
+ sendStore.update(messageId) { it.copy(state = state) }
+ publishPendingSends()
+ }
+
+ private fun removePendingSend(messageId: String) {
+ sendStore.remove(messageId)
+ viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) }
+ publishPendingSends()
+ }
+
+ private fun removeUnconfirmedBubble(messageId: String) {
+ val row = _uiState.value.messages.firstOrNull { it.id == messageId && it.canonicalRestId == null } ?: return
+ _uiState.update { state -> state.copy(messages = state.messages.filterNot { it.id == row.id }) }
+ viewModelScope.launch(ioDispatcher) { repo.deleteMessage(row.id) }
+ }
+
+ private fun failOutgoingBeforeSubmit(
+ messageId: String,
+ reason: String,
+ generation: Long = sessionGeneration,
+ ) {
+ markPendingSend(messageId, PendingSendState.REJECTED)
+ if (generation != sessionGeneration) return
+ removeUnconfirmedBubble(messageId)
+ _uiState.update { it.copy(errorMessage = reason, isAgentTyping = mainTurnBusy) }
+ }
+
+ /** Send one locally queued turn only after the previous receipt has reached REST history. */
+ @Synchronized
+ private fun drainPendingQueue() {
+ val sessionId = _uiState.value.currentSessionId ?: return
+ val runtimeId = runtimeSessionId ?: return
+ val scope = sendScope()
+ if (mainTurnBusy || !_uiState.value.isSessionReady ||
+ wsClient.connectionStatus.value != ConnectionStatus.CONNECTED
+ ) {
+ return
+ }
+ if (queueDrainJob?.isActive == true) return
+ if (hasPendingSendReservation(scope, sessionId)) return
+ val rows = sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
+ if (rows.any {
+ it.state == PendingSendState.SENDING || it.state == PendingSendState.UNKNOWN ||
+ it.state == PendingSendState.ACCEPTED
+ }
+ ) {
+ return
+ }
+ val next = rows.firstOrNull { it.state == PendingSendState.QUEUED } ?: return
+ if (queuedStagingIds.contains(next.id)) return
+ val owner = captureSendOwner(sessionId, runtimeId, scope, sessionGeneration)
+ queueDrainJob =
+ viewModelScope.launch(ioDispatcher) {
+ try {
+ val staged = stageQueuedAttachments(next)
+ if (withCurrentSendOwner(owner) { true } != true) return@launch
+ val message =
+ _uiState.value.messages.firstOrNull { it.id == next.id }
+ ?: ChatMessage(
+ id = next.id,
+ role = MessageRole.USER,
+ content = next.text,
+ attachments = staged.attachments,
+ )
+ dispatchPrompt(
+ text = next.text,
+ attachments = staged.attachments,
+ wasStreaming = false,
+ storageSessionId = owner.storageSessionId,
+ agentSessionId = owner.agentSessionId,
+ userMessage = message,
+ mode = BusySendMode.QUEUE,
+ queued = true,
+ owner = owner,
+ pendingReceipt =
+ staged.copy(
+ state = PendingSendState.SENDING,
+ attempts = next.attempts + 1,
+ ),
+ rollbackReceipt = next,
+ ).join()
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ failOutgoingBeforeSubmit(
+ next.id,
+ if (e is QueuedAttachmentTooLargeException) {
+ attachmentTooLargeMessage(e.attachment)
+ } else {
+ "Could not retain queued attachment"
+ },
+ )
+ }
+ }
+ }
+
+ private fun stageQueuedAttachments(row: PendingSend): PendingSend {
+ if (row.attachments.isEmpty()) return row
+ val context = getApplication()
+ val dir = File(context.filesDir, "chat-send/${row.id}")
+ if (!dir.exists() && !dir.mkdirs()) error("Could not create attachment snapshot")
+ val staged =
+ row.attachments.mapIndexed { index, attachment ->
+ val uri = Uri.parse(attachment.uri)
+ val target = File(dir, index.toString())
+ if (target.exists()) {
+ attachment.copy(uri = Uri.fromFile(target).toString(), size = target.length())
+ } else {
+ val partial = File(dir, "$index.part")
+ var copied = 0L
+ try {
+ val input = context.contentResolver.openInputStream(uri) ?: error("Attachment unavailable")
+ input.use { source ->
+ partial.outputStream().use { output ->
+ val buffer = ByteArray(8192)
+ while (true) {
+ val count = source.read(buffer)
+ if (count < 0) break
+ copied += count
+ if (copied > MAX_CHAT_ATTACHMENT_BYTES) {
+ throw QueuedAttachmentTooLargeException(attachment)
+ }
+ output.write(buffer, 0, count)
+ }
+ }
+ }
+ if (!partial.renameTo(target)) error("Could not save attachment snapshot")
+ attachment.copy(uri = Uri.fromFile(target).toString(), size = copied)
+ } catch (e: Exception) {
+ partial.delete()
+ throw e
+ }
+ }
+ }
+ return row.copy(attachments = staged)
+ }
+
+ private fun refreshSendReceipts() {
+ val sessionId = _uiState.value.currentSessionId ?: return
+ if (sendStore.all().any {
+ it.scope == sendScope() && it.sessionId == sessionId &&
+ it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
+ }
+ ) {
+ if (sessionHasServerPresence) syncCurrentSession() else loadSessionMessages(sessionId, sessionGeneration)
+ } else {
+ drainPendingQueue()
+ }
+ }
+
+ /** A manual promotion is the only path that may retry an uncertain send. */
+ @Synchronized
+ fun sendQueuedNow(id: String) {
+ val row = sendStore.all().firstOrNull { it.id == id } ?: return
+ if (row.scope != sendScope() || row.sessionId != _uiState.value.currentSessionId ||
+ runtimeSessionId == null
+ ) {
+ return
+ }
+ if (row.state !in
+ setOf(
+ PendingSendState.QUEUED,
+ PendingSendState.PARKED,
+ PendingSendState.REJECTED,
+ PendingSendState.UNKNOWN,
+ PendingSendState.ACCEPTED,
+ )
+ ) {
+ return
+ }
+ if (queueDrainJob?.isActive == true ||
+ synchronized(pendingSendReservationLock) { pendingSendReservations.any { it.pending.id == id } }
+ ) {
+ return
+ }
+ val others =
+ sendStore.all().any {
+ it.id != id && it.scope == row.scope && it.sessionId == row.sessionId &&
+ it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
+ }
+ if (others) {
+ _uiState.update { it.copy(errorMessage = "Wait for the current send to settle") }
+ return
+ }
+ if (row.attachments.isNotEmpty()) {
+ val runtimeId = runtimeSessionId ?: return
+ val generation = sessionGeneration
+ val wasStreaming = mainTurnBusy
+ val message =
+ _uiState.value.messages.firstOrNull { it.id == row.id }
+ ?: ChatMessage(
+ id = row.id,
+ role = MessageRole.USER,
+ content = row.text,
+ attachments = row.attachments,
+ )
+ enqueuePendingSendReservation(
+ PendingSendReservation(
+ pending =
+ row.copy(
+ mode = if (wasStreaming) BusySendMode.INTERRUPT else BusySendMode.QUEUE,
+ state = PendingSendState.SENDING,
+ attempts = row.attempts + 1,
+ ),
+ userMessage = message,
+ wasStreaming = wasStreaming,
+ storageSessionId = row.sessionId,
+ agentSessionId = runtimeId,
+ scope = row.scope,
+ generation = generation,
+ mainTurnEpoch = mainTurnEpoch,
+ originalPending = row,
+ ),
+ )
+ return
+ }
+ sendStore.promote(id)
+ publishPendingSends()
+ if (mainTurnBusy) {
+ val runtimeId = runtimeSessionId ?: return
+ val owner = captureSendOwner(row.sessionId, runtimeId, row.scope, sessionGeneration)
+ val interruptedTurnEpoch = mainTurnEpoch
+ viewModelScope.launch {
+ commitReceiptAndEnqueue(
+ owner,
+ row.copy(state = PendingSendState.SENDING, attempts = row.attempts + 1),
+ row,
+ deleteSnapshotOnRollback = false,
+ ) {
+ wsClient.send(
+ WsMethods.SESSION_INTERRUPT,
+ mapOf("session_id" to owner.agentSessionId),
+ onSent = { requestId -> trackHardInterrupt(requestId, id, owner, interruptedTurnEpoch) },
+ )
+ }
+ }
+ } else {
+ drainPendingQueue()
+ }
+ }
+
/**
* Send a user prompt, uploading any pending attachments to the backend
* first via their dedicated RPC methods.
@@ -1837,7 +2645,10 @@ class ChatViewModel(
* 6. For each file → await `file.attach` (requires session_id), collect @file: refs
* 7. Send `prompt.submit` with text + @file: refs — images auto-picked up by backend
*/
- fun sendMessage(text: String): Boolean {
+ fun sendMessage(
+ text: String,
+ modeOverride: BusySendMode? = null,
+ ): Boolean {
if (!canSubmitMessage()) return false
if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return false
@@ -1861,17 +2672,45 @@ class ChatViewModel(
_uiState.update {
it.copy(
errorMessage = attachmentTooLargeMessage(oversizedAttachment),
- composerTextToRestore = text,
)
}
- return true
+ return false
}
- // Snapshot + clear attachments so the input bar empties immediately
val attachments = _uiState.value.pendingAttachments.toList()
- clearAttachments()
+ val submissionKey =
+ listOf(_uiState.value.currentSessionId, text, attachments.joinToString { it.uri }).joinToString("\u001f")
+ val now = System.nanoTime()
+ if (submissionKey == lastSubmissionKey && now - lastSubmissionAt < 500_000_000L) return false
+ val wasStreaming = mainTurnBusy
+ val clickedMainTurnEpoch = mainTurnEpoch
+ val hasOutstandingDelivery =
+ sendStore.all().any {
+ it.scope == sendScope() && it.sessionId == _uiState.value.currentSessionId &&
+ it.state in
+ setOf(
+ PendingSendState.SENDING,
+ PendingSendState.ACCEPTED,
+ PendingSendState.UNKNOWN,
+ )
+ } || hasPendingSendReservation(sendScope(), _uiState.value.currentSessionId)
+ val busy = wasStreaming || hasOutstandingDelivery
+ val selectedMode = modeOverride ?: _uiState.value.busySendMode
+ val mode =
+ when {
+ selectedMode == BusySendMode.INTERRUPT && !wasStreaming && hasOutstandingDelivery -> {
+ BusySendMode.QUEUE
+ }
- val wasStreaming = _uiState.value.isAgentTyping
+ busy && selectedMode != BusySendMode.INTERRUPT &&
+ (attachments.isNotEmpty() || !wasStreaming) -> {
+ BusySendMode.QUEUE
+ }
+
+ else -> {
+ selectedMode
+ }
+ }
val userMessage =
ChatMessage(
@@ -1882,21 +2721,132 @@ class ChatViewModel(
messageProvenance = MessageProvenance.LOCAL_PENDING,
)
- // Update UI immediately
+ val storageSessionId = _uiState.value.currentSessionId
+ val agentSessionId = runtimeSessionId
+ val scope = sendScope()
+ val createdAt = nextPendingSendCreatedAt()
+ if (storageSessionId != null && agentSessionId != null &&
+ (attachments.isNotEmpty() || hasPendingSendReservation(scope, storageSessionId))
+ ) {
+ val generation = sessionGeneration
+ val pending =
+ PendingSend(
+ id = userMessage.id,
+ scope = scope,
+ sessionId = storageSessionId,
+ text = text,
+ attachments = attachments,
+ mode = mode,
+ state =
+ if (busy && mode == BusySendMode.QUEUE) {
+ PendingSendState.QUEUED
+ } else {
+ PendingSendState.SENDING
+ },
+ createdAt = createdAt,
+ )
+ lastSubmissionKey = submissionKey
+ lastSubmissionAt = now
+ clearAttachments()
+ enqueuePendingSendReservation(
+ PendingSendReservation(
+ pending = pending,
+ userMessage = userMessage,
+ wasStreaming = wasStreaming,
+ storageSessionId = storageSessionId,
+ agentSessionId = agentSessionId,
+ scope = scope,
+ generation = generation,
+ mainTurnEpoch = clickedMainTurnEpoch,
+ ),
+ )
+ return true
+ }
+ if (storageSessionId != null && agentSessionId != null && busy && mode == BusySendMode.QUEUE) {
+ try {
+ sendStore.put(
+ PendingSend(
+ id = userMessage.id,
+ scope = scope,
+ sessionId = storageSessionId,
+ text = text,
+ attachments = attachments,
+ mode = mode,
+ createdAt = createdAt,
+ state =
+ if (busy && mode == BusySendMode.QUEUE) {
+ PendingSendState.QUEUED
+ } else {
+ PendingSendState.SENDING
+ },
+ ),
+ )
+ } catch (e: Exception) {
+ _uiState.update { it.copy(errorMessage = "Could not save message for delivery") }
+ return false
+ }
+ }
+ lastSubmissionKey = submissionKey
+ lastSubmissionAt = now
+ clearAttachments()
+
_uiState.update { state ->
state.copy(
messages = state.messages + userMessage,
- isAgentTyping = true,
+ isSending = true,
)
}
-
- val storageSessionId = _uiState.value.currentSessionId
- val agentSessionId = runtimeSessionId
+ publishPendingSends()
if (storageSessionId == null || agentSessionId == null) {
// Issue #969: Session creation is still in-flight. Hold the prompt
// so it is dispatched automatically the moment SESSION_CREATE lands.
- pendingInitialPrompt = PendingPrompt(text, attachments, wasStreaming, userMessage)
+ pendingInitialPrompt =
+ PendingPrompt(text, attachments, wasStreaming, userMessage, createdAt, clickedMainTurnEpoch)
+ publishPendingSends()
+ return true
+ }
+
+ if (busy && mode == BusySendMode.QUEUE) {
+ queuedStagingIds.add(userMessage.id)
+ viewModelScope.launch(ioDispatcher) {
+ try {
+ stageQueuedAttachments(sendStore.all().first { it.id == userMessage.id })
+ repo.persistMessage(userMessage, storageSessionId)
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ failOutgoingBeforeSubmit(userMessage.id, "Could not retain queued attachment")
+ } finally {
+ queuedStagingIds.remove(userMessage.id)
+ drainPendingQueue()
+ }
+ }
+ return true
+ }
+
+ if (wasStreaming && mode == BusySendMode.INTERRUPT) {
+ val owner = captureSendOwner(storageSessionId, agentSessionId, scope, sessionGeneration)
+ val interruptedTurnEpoch = mainTurnEpoch
+ val receipt =
+ PendingSend(
+ id = userMessage.id,
+ scope = scope,
+ sessionId = storageSessionId,
+ text = text,
+ attachments = attachments,
+ mode = mode,
+ state = PendingSendState.SENDING,
+ createdAt = createdAt,
+ )
+ viewModelScope.launch {
+ commitReceiptAndEnqueue(owner, receipt, null, deleteSnapshotOnRollback = false) {
+ wsClient.send(
+ WsMethods.SESSION_INTERRUPT,
+ mapOf("session_id" to owner.agentSessionId),
+ onSent = { id -> trackHardInterrupt(id, userMessage.id, owner, interruptedTurnEpoch) },
+ )
+ }
+ }
return true
}
@@ -1907,6 +2857,19 @@ class ChatViewModel(
storageSessionId = storageSessionId,
agentSessionId = agentSessionId,
userMessage = userMessage,
+ mode = if (wasStreaming) mode else BusySendMode.CORRECT,
+ owner = captureSendOwner(storageSessionId, agentSessionId, scope),
+ pendingReceipt =
+ PendingSend(
+ id = userMessage.id,
+ scope = scope,
+ sessionId = storageSessionId,
+ text = text,
+ attachments = attachments,
+ mode = if (wasStreaming) mode else BusySendMode.CORRECT,
+ state = PendingSendState.SENDING,
+ createdAt = createdAt,
+ ),
)
return true
}
@@ -1933,8 +2896,15 @@ class ChatViewModel(
storageSessionId: String,
agentSessionId: String,
userMessage: ChatMessage? = null,
- ) {
- val dispatchGeneration = sessionGeneration
+ mode: BusySendMode = BusySendMode.CORRECT,
+ queued: Boolean = false,
+ owner: SendOwner = captureSendOwner(storageSessionId, agentSessionId),
+ pendingReceipt: PendingSend? = null,
+ rollbackReceipt: PendingSend? = null,
+ deleteSnapshotOnRollback: Boolean = false,
+ outboundAlreadyStarted: Boolean = false,
+ ): Job {
+ val dispatchGeneration = owner.generation
AuthManager.setLastOpenedSessionId(storageSessionId)
val msgToPersist =
userMessage ?: ChatMessage(
@@ -1944,16 +2914,52 @@ class ChatViewModel(
tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 },
messageProvenance = MessageProvenance.LOCAL_PENDING,
)
+ val attemptedReceipt =
+ pendingReceipt
+ ?: sendStore.all().firstOrNull { it.id == msgToPersist.id }?.copy(state = PendingSendState.SENDING)
+ ?: PendingSend(
+ id = msgToPersist.id,
+ scope = owner.scope,
+ sessionId = owner.storageSessionId,
+ text = text,
+ attachments = attachments,
+ mode = mode,
+ state = PendingSendState.SENDING,
+ )
- // Upload attachments then submit prompt
- viewModelScope.launch(ioDispatcher) {
+ // PR #1254: callers draining FIFO work retain ownership until preparation/enqueue ends.
+ return viewModelScope.launch(ioDispatcher) {
val fileRefs = mutableListOf()
val preparedAttachments = mutableListOf()
+ var receiptCommitted = outboundAlreadyStarted
+ var outboundStarted = outboundAlreadyStarted
+
+ suspend fun enqueueOwned(action: () -> T): T? {
+ val value =
+ if (!receiptCommitted) {
+ commitReceiptAndEnqueue(
+ owner,
+ attemptedReceipt,
+ rollbackReceipt,
+ deleteSnapshotOnRollback,
+ action,
+ )
+ } else {
+ withCurrentSendOwner(owner, action)
+ }
+ if (value == null) {
+ if (outboundStarted) {
+ sendStore.update(msgToPersist.id) { it.copy(state = PendingSendState.UNKNOWN) }
+ publishPendingSends()
+ }
+ return null
+ }
+ receiptCommitted = true
+ outboundStarted = true
+ return value
+ }
try {
- // Snapshot every attachment before the first RPC. This closes the
- // content-URI TOCTOU window without retaining multiple Base64
- // strings in the heap: encoded snapshots live in private cache.
for (attachment in attachments) {
when (val result = prepareAttachment(attachment)) {
is PrepareAttachmentResult.Success -> {
@@ -1961,30 +2967,81 @@ class ChatViewModel(
}
PrepareAttachmentResult.TooLarge -> {
- rejectOversizedAttachment(
- attachment = attachment,
- attachments = attachments,
- message = msgToPersist,
- wasStreaming = wasStreaming,
- generation = dispatchGeneration,
- )
+ if (!outboundStarted) {
+ restoreKnownUnsentReceipt(
+ attemptedReceipt,
+ rollbackReceipt,
+ deleteSnapshotOnRollback,
+ )
+ }
+ if (!outboundStarted && rollbackReceipt != null) {
+ withCurrentSendOwner(owner) {
+ removeUnconfirmedBubble(msgToPersist.id)
+ _uiState.update { it.copy(errorMessage = attachmentTooLargeMessage(attachment)) }
+ }
+ } else {
+ rejectOversizedAttachment(
+ attachment,
+ attachments,
+ msgToPersist,
+ wasStreaming,
+ dispatchGeneration,
+ )
+ }
return@launch
}
PrepareAttachmentResult.Unreadable -> {
- Log.w(TAG, "Skipping unreadable attachment: ${attachment.name}")
+ if (!outboundStarted) {
+ restoreKnownUnsentReceipt(
+ attemptedReceipt,
+ rollbackReceipt,
+ deleteSnapshotOnRollback,
+ )
+ }
+ if (!outboundStarted && rollbackReceipt != null) {
+ withCurrentSendOwner(owner) {
+ removeUnconfirmedBubble(msgToPersist.id)
+ _uiState.update {
+ it.copy(
+ errorMessage = "Attachment unavailable: ${attachment.name}",
+ isAgentTyping = mainTurnBusy,
+ )
+ }
+ }
+ } else {
+ failOutgoingBeforeSubmit(
+ msgToPersist.id,
+ "Attachment unavailable: ${attachment.name}",
+ dispatchGeneration,
+ )
+ }
+ return@launch
}
}
}
- // Persist only after every readable attachment has a bounded,
- // immutable snapshot and no partial server upload can occur.
+ if (withCurrentSendOwner(owner) { true } != true) {
+ if (!outboundStarted) {
+ restoreKnownUnsentReceipt(
+ attemptedReceipt,
+ rollbackReceipt,
+ deleteSnapshotOnRollback,
+ )
+ }
+ return@launch
+ }
try {
- repo.persistMessage(msgToPersist, storageSessionId)
+ repo.persistMessage(msgToPersist, owner.storageSessionId)
} catch (e: Exception) {
- if (e is kotlinx.coroutines.CancellationException) throw e
+ if (e is CancellationException) throw e
Log.e(TAG, "Failed to persist outgoing message", e)
- if (dispatchGeneration == sessionGeneration) {
+ if (outboundStarted) {
+ sendStore.update(msgToPersist.id) { it.copy(state = PendingSendState.UNKNOWN) }
+ } else {
+ restoreKnownUnsentReceipt(attemptedReceipt, rollbackReceipt, deleteSnapshotOnRollback)
+ }
+ if (isCurrentSendContext(owner)) {
_uiState.update { state ->
state.copy(
messages = state.messages.filterNot { it.id == msgToPersist.id },
@@ -2000,68 +3057,58 @@ class ChatViewModel(
for ((attachment, encodedFile) in preparedAttachments) {
try {
- // Materialize one bounded Base64 payload at a time. The
- // awaited RPC completes before the next snapshot is read.
val b64 = encodedFile.readText(Charsets.US_ASCII)
-
if (attachment.isImage) {
- // Await so the backend stages the image into
- // session["attached_images"] BEFORE prompt.submit runs
- // (a fire-and-forget send raced prompt.submit and the
- // image was dropped). Requires session_id or the gateway
- // 4001s "session not found" (desktop passes it too).
- val result =
- sendRpcAndAwait(
- method = WsMethods.IMAGE_ATTACH_BYTES,
- params =
+ val deferred =
+ enqueueOwned {
+ wsClient.request(
+ WsMethods.IMAGE_ATTACH_BYTES,
mapOf(
- "session_id" to agentSessionId,
+ "session_id" to owner.agentSessionId,
"content_base64" to "data:${attachment.mimeType};base64,$b64",
"filename" to attachment.name,
"ext" to attachment.fileExtension,
),
- ).let { if (it is JsonElement) it.toAny() else it }
- if (result != null) {
- @Suppress("UNCHECKED_CAST")
- val ok = (result as? Map)?.get("attached") as? Boolean
- if (ok != true) {
- Log.w(TAG, "Image attach for ${attachment.name} returned non-ok: $result")
- }
- }
- } else {
- // Await the @file: ref text so we can embed it in the prompt.
- // file.attach also requires session_id or the gateway 4001s
- // "session not found" (same resolver as image.attach_bytes).
- sendRpcAndAwait(
- method = WsMethods.FILE_ATTACH,
- params =
- mapOf(
- "session_id" to agentSessionId,
- "data_url" to "data:${attachment.mimeType};base64,$b64",
- "name" to attachment.name,
- ),
- )?.let { response ->
- // request() returns raw JsonElement values; normalize before reading Kotlin types.
- val result = if (response is JsonElement) response.toAny() else response
+ )
+ } ?: return@launch
+ val response = deferred.await()
+ val result = if (response is JsonElement) response.toAny() else response
- @Suppress("UNCHECKED_CAST")
- val refText =
- (result as? Map)?.get("ref_text") as? String
- if (!refText.isNullOrBlank()) fileRefs.add(refText)
- }
+ @Suppress("UNCHECKED_CAST")
+ val ok = (result as? Map)?.get("attached") as? Boolean
+ if (ok != true) error("Image attachment was not accepted")
+ } else {
+ val deferred =
+ enqueueOwned {
+ wsClient.request(
+ WsMethods.FILE_ATTACH,
+ mapOf(
+ "session_id" to owner.agentSessionId,
+ "data_url" to "data:${attachment.mimeType};base64,$b64",
+ "name" to attachment.name,
+ ),
+ )
+ } ?: return@launch
+ val response = deferred.await()
+ val result = if (response is JsonElement) response.toAny() else response
+
+ @Suppress("UNCHECKED_CAST")
+ val refText = (result as? Map)?.get("ref_text") as? String
+ if (refText.isNullOrBlank()) error("File attachment was not accepted")
+ fileRefs.add(refText)
}
} catch (e: Exception) {
- if (e is kotlinx.coroutines.CancellationException) throw e
+ if (e is CancellationException) throw e
Log.e(TAG, "Failed to upload attachment ${attachment.name}", e)
- if (dispatchGeneration == sessionGeneration) {
- _uiState.update {
- it.copy(errorMessage = "Upload failed: ${attachment.name}")
- }
- }
+ failOutgoingBeforeSubmit(
+ msgToPersist.id,
+ "Upload failed: ${attachment.name}",
+ dispatchGeneration,
+ )
+ return@launch
}
}
- // Build prompt text — prepend @file: refs for non-image files
val fullText =
if (fileRefs.isEmpty()) {
text
@@ -2069,53 +3116,69 @@ class ChatViewModel(
fileRefs.joinToString("\n") +
if (text.isNotBlank()) "\n\n$text" else ""
}
-
- if (dispatchGeneration != sessionGeneration) return@launch
-
- // While a turn is actively streaming and this is a plain text prompt
- // (no attachments — session.redirect carries text only), steer the
- // in-flight turn via session.redirect instead of queueing a fresh
- // prompt.submit. The backend rewrites the live turn when it can, or
- // queues the correction as the next turn otherwise (issue #710).
- if (dispatchGeneration == sessionGeneration) {
- ActiveSessionHolder.set(agentSessionId, storageSessionId)
+ if (isCurrentSendContext(owner)) {
+ ActiveSessionHolder.set(
+ owner.agentSessionId,
+ owner.storageSessionId,
+ )
}
captureTurnUsageBaselineIfNeeded()
- if (wasStreaming && attachments.isEmpty()) {
- wsClient.sendRedirect(
- agentSessionId,
- fullText,
- onSent = { id ->
- trackSessionRequest(
- id = id,
- method = WsMethods.SESSION_REDIRECT,
- generation = dispatchGeneration,
- sessionId = storageSessionId,
- )
- },
- )
+ if (wasStreaming && attachments.isEmpty() && !queued) {
+ val sent =
+ if (mode == BusySendMode.GUIDE) {
+ enqueueOwned {
+ wsClient.send(
+ WsMethods.SESSION_STEER,
+ mapOf("session_id" to owner.agentSessionId, "text" to fullText),
+ onSent = { id ->
+ trackOutgoingRequest(
+ id,
+ msgToPersist.id,
+ WsMethods.SESSION_STEER,
+ dispatchGeneration,
+ owner.storageSessionId,
+ )
+ },
+ )
+ }
+ } else {
+ enqueueOwned {
+ wsClient.sendRedirect(
+ owner.agentSessionId,
+ fullText,
+ onSent = { id ->
+ trackOutgoingRequest(
+ id,
+ msgToPersist.id,
+ WsMethods.SESSION_REDIRECT,
+ dispatchGeneration,
+ owner.storageSessionId,
+ )
+ },
+ )
+ }
+ }
+ if (sent == null) return@launch
} else {
- // Arm the durable turn boundary BEFORE prompt.submit leaves
- // the device: the REST high-watermark has to be read while
- // the reply row cannot exist yet. A failed capture is
- // non-fatal — the prompt is still sent, the turn just
- // becomes uncorrelatable and its reply notification is never
- // auto-dismissed from hydration instead of being bound to a
- // guessed row.
- prepareTurnCorrelation(storageSessionId)
- if (dispatchGeneration != sessionGeneration) return@launch
- wsClient.sendMessage(
- agentSessionId,
- fullText,
- onSent = { id ->
- trackSessionRequest(
- id = id,
- method = WsMethods.PROMPT_SUBMIT,
- generation = dispatchGeneration,
- sessionId = storageSessionId,
+ if (!queued) prepareTurnCorrelation(owner.storageSessionId)
+ val sent =
+ enqueueOwned {
+ wsClient.sendMessage(
+ owner.agentSessionId,
+ fullText,
+ onSent = { id ->
+ trackOutgoingRequest(
+ id,
+ msgToPersist.id,
+ WsMethods.PROMPT_SUBMIT,
+ dispatchGeneration,
+ owner.storageSessionId,
+ )
+ },
+ queued = queued,
)
- },
- )
+ }
+ if (sent == null) return@launch
}
} finally {
preparedAttachments.forEach { it.encodedFile.delete() }
@@ -2188,6 +3251,7 @@ class ChatViewModel(
) {
Log.w(TAG, "Rejecting oversized attachment: ${attachment.name}")
if (generation != sessionGeneration) return
+ removePendingSend(message.id)
_uiState.update { state ->
state.copy(
messages = state.messages.filterNot { it.id == message.id },
@@ -2276,7 +3340,7 @@ class ChatViewModel(
_uiState.update { it.copy(messages = it.messages + userMsg) }
// Persist — OUTSIDE update{}
- if (sessionId != null) {
+ if (sessionId != null && result !is SlashResult.QueuePrompt) {
viewModelScope.launch(ioDispatcher) {
repo.persistMessage(userMsg, sessionId)
}
@@ -2355,7 +3419,7 @@ class ChatViewModel(
}
is SlashResult.QueuePrompt -> {
- handleQueueCommand(command)
+ handleQueueCommand(command, userMsg)
}
is SlashResult.SideQuestion -> {
@@ -2381,13 +3445,54 @@ class ChatViewModel(
* `command.dispatch` `queue` shim only echoes the text back as a plain
* submit, which loses the queued flag and hijacks the live turn.
*/
- private fun handleQueueCommand(command: String) {
+ private fun handleQueueCommand(
+ command: String,
+ userMessage: ChatMessage,
+ ) {
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
if (arg.isBlank()) {
addAssistantMessage("usage: /queue ")
return
}
- submitPrompt(arg, queued = true)
+ val storageId = _uiState.value.currentSessionId ?: return
+ val runtimeId = runtimeSessionId ?: return
+ val busy =
+ mainTurnBusy ||
+ sendStore.all().any {
+ it.scope == sendScope() && it.sessionId == storageId &&
+ it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
+ }
+ try {
+ sendStore.put(
+ PendingSend(
+ id = userMessage.id,
+ scope = sendScope(),
+ sessionId = storageId,
+ text = arg,
+ mode = BusySendMode.QUEUE,
+ state = if (busy) PendingSendState.QUEUED else PendingSendState.SENDING,
+ ),
+ )
+ } catch (e: Exception) {
+ removeUnconfirmedBubble(userMessage.id)
+ _uiState.update { it.copy(errorMessage = "Could not save message for delivery") }
+ return
+ }
+ publishPendingSends()
+ if (busy) {
+ viewModelScope.launch(ioDispatcher) { repo.persistMessage(userMessage, storageId) }
+ } else {
+ dispatchPrompt(
+ text = arg,
+ attachments = emptyList(),
+ wasStreaming = false,
+ storageSessionId = storageId,
+ agentSessionId = runtimeId,
+ userMessage = userMessage,
+ mode = BusySendMode.QUEUE,
+ queued = true,
+ )
+ }
}
private fun handleReasoningSlashCommand(arg: String) {
@@ -2786,6 +3891,10 @@ class ChatViewModel(
fun interruptSession() {
val sessionId = runtimeSessionId ?: return
+ _uiState.value.currentSessionId?.let { storageId ->
+ sendStore.park(sendScope(), storageId)
+ publishPendingSends()
+ }
viewModelScope.launch(ioDispatcher) {
wsClient.send(
WsMethods.SESSION_INTERRUPT,
@@ -2944,6 +4053,7 @@ class ChatViewModel(
_uiState.update { state ->
state.copy(
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
+ busySendMode = AuthManager.getBusySendMode(),
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
messageStatsEnabled = AuthManager.isMessageStatsEnabled(),
showUserMessageTokens = AuthManager.isUserMessageTokensEnabled(),
@@ -2952,6 +4062,7 @@ class ChatViewModel(
showModelProvider = AuthManager.isModelProviderShown(),
)
}
+ publishPendingSends()
}
/**
@@ -3303,6 +4414,31 @@ class ChatViewModel(
): List? {
while (isCurrent()) {
val snapshot = _uiState.value
+ // Receipt bubbles can be absent after rejection/reconnect. Offer their identities
+ // to the canonical matcher without displaying unconfirmed messages again.
+ val visibleIds = snapshot.messages.map { it.id }.toSet()
+ val receiptCandidates =
+ if (cached) {
+ emptyList()
+ } else {
+ sendStore
+ .all()
+ .filter {
+ it.scope == sendScope() && it.sessionId == snapshot.currentSessionId &&
+ it.id !in visibleIds &&
+ it.state in
+ setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
+ }.map {
+ ChatMessage(
+ id = it.id,
+ role = MessageRole.USER,
+ content = it.text,
+ attachments = it.attachments,
+ timestamp = it.createdAt,
+ )
+ }
+ }
+ val receiptCandidateIds = receiptCandidates.map { it.id }.toSet()
val computed =
withContext(historyDispatcher) {
val mapped = mapPage(snapshot.messages)
@@ -3334,10 +4470,10 @@ class ChatViewModel(
} else {
mergeTranscriptWithLive(
page,
- snapshot.messages,
+ snapshot.messages + receiptCandidates,
chronological = !prepend,
preserveLiveIds = true,
- )
+ ).filterNot { it.id in receiptCandidateIds && it.canonicalRestId == null }
}
val stableMessages = if (merged == snapshot.messages) snapshot.messages else merged
Triple(page, stableMessages, hydrateTodosFromMessages(merged).ifEmpty { snapshot.todos })
@@ -3517,6 +4653,12 @@ class ChatViewModel(
)
repo.confirmIdentities(aliases, sessionId)
}
+ val scope = sendScope()
+ val scopedPending =
+ sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
+ pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending).forEach(::removePendingSend)
+ publishPendingSends()
+ drainPendingQueue()
}
// ── Session resume recovery (desktop parity) ─────────────────────────
@@ -3552,7 +4694,12 @@ class ChatViewModel(
isLoading: Boolean,
): Long {
val generation = ++sessionGeneration
+ // A slow drain belongs to the old session and must not block the resumed queue.
+ queueDrainJob?.cancel()
+ queueDrainJob = null
connectionOperationDelegate.reset()
+ mainTurnBusy = false
+ lastMainCompletionAt = 0L
cancelResumeRetry()
contextUsageJob?.cancel()
contextUsageJob = null
@@ -3586,6 +4733,7 @@ class ChatViewModel(
currentSessionId = sessionId,
chatTitle = title,
isAgentTyping = false,
+ isSending = false,
isThinking = false,
thinkingText = "",
isLoading = isLoading,
@@ -3626,6 +4774,7 @@ class ChatViewModel(
pendingPrefillText = null,
)
}
+ publishPendingSends()
return generation
}
@@ -3691,6 +4840,7 @@ class ChatViewModel(
isSessionReady = runtimeSessionId != null,
)
}
+ drainPendingQueue()
}
private fun resumeRetryDelayMs(attempt: Int): Long =
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt
index cf67124f..331528d1 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat.components
+import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
@@ -37,6 +38,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -59,6 +61,7 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
+import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.ws.CommandBlocklist
import com.m57.hermescontrol.data.ws.CommandCatalog
@@ -75,9 +78,11 @@ fun ChatInputBar(
inputFieldValue: TextFieldValue,
onInputChange: (TextFieldValue) -> Unit,
onSend: () -> Unit,
+ onBusySend: (BusySendMode) -> Unit = {},
onMicTap: () -> Unit,
isListening: Boolean,
isAgentTyping: Boolean,
+ isMainTurnBusy: Boolean = false,
isConnected: Boolean,
commandCatalog: CommandCatalog,
isSessionReady: Boolean,
@@ -111,10 +116,14 @@ fun ChatInputBar(
val canSend =
pendingReasoningLevel == null &&
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
+ val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty()
+ val isSlashDraft = inputFieldValue.text.trimStart().startsWith("/")
+ val showBusyActions = isMainTurnBusy && hasDraft && !isSlashDraft
// Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) }
val palette = composerPalette()
+ BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false }
AnimatedVisibility(
visible = true,
@@ -319,8 +328,11 @@ fun ChatInputBar(
reasoningLevel = reasoningLevel,
isListening = isListening,
canSend = canSend,
- showSend = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty(),
+ showSend = hasDraft,
+ showQueue = showBusyActions,
onSend = onSend,
+ onQueue = { onBusySend(BusySendMode.QUEUE) },
+ onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) },
onAttachTap = { showAttachmentTray = !showAttachmentTray },
onModelTap = onModelTap,
onReasoningSelected = onReasoningTap,
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt
index bfee7e24..943967ef 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt
@@ -6,8 +6,10 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -27,6 +29,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.AddToQueue
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Stop
@@ -56,7 +59,9 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -83,6 +88,7 @@ import com.m57.hermescontrol.R
* When [supportsReasoning] is false the model takes no reasoning parameter
* and the level menu is disabled.
*/
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ComposerToolbar(
isConnected: Boolean,
@@ -96,7 +102,10 @@ fun ComposerToolbar(
modifier: Modifier = Modifier,
canSend: Boolean = false,
showSend: Boolean = canSend,
+ showQueue: Boolean = false,
onSend: () -> Unit = {},
+ onQueue: () -> Unit = {},
+ onStopAndSend: () -> Unit = {},
canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null,
fastMode: Boolean = false,
@@ -430,29 +439,62 @@ fun ComposerToolbar(
}
}
+ AnimatedVisibility(
+ visible = showQueue,
+ enter = fadeIn() + scaleIn(initialScale = 0.8f),
+ exit = fadeOut() + scaleOut(targetScale = 0.8f),
+ ) {
+ FilledIconButton(
+ onClick = onQueue,
+ enabled = canSend,
+ colors = flatIconButtonColors(palette),
+ modifier = Modifier.size(48.dp).testTag("queue_button"),
+ ) {
+ Icon(
+ imageVector = Icons.Default.AddToQueue,
+ contentDescription = stringResource(R.string.chat_busy_queue_action),
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+
// Action button — send when a send is possible, mic / stop otherwise
- FilledIconButton(
- onClick = if (showSend) onSend else onMicTap,
- enabled = if (showSend) canSend else isConnected,
- colors =
- if (!showSend && isListening) {
- listeningIconButtonColors()
- } else {
- IconButtonDefaults.filledIconButtonColors(
- containerColor = palette.action,
- contentColor = palette.onAction,
- )
- },
+ val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send)
+ val actionEnabled = if (showSend) canSend else isConnected
+ Box(
modifier =
Modifier
- .size(ControlSize)
- .testTag(
+ .size(if (showSend) 48.dp else ControlSize)
+ .clip(CircleShape)
+ .background(
+ if (!showSend && isListening) {
+ MaterialTheme.colorScheme.errorContainer
+ } else {
+ palette.action
+ },
+ ).semantics {
+ if (showSend && showQueue && canSend) {
+ customActions =
+ listOf(
+ CustomAccessibilityAction(stopAndSendLabel) {
+ onStopAndSend()
+ true
+ },
+ )
+ }
+ }.combinedClickable(
+ enabled = actionEnabled,
+ onClick = if (showSend) onSend else onMicTap,
+ onLongClick = if (showSend && showQueue) onStopAndSend else null,
+ onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null,
+ ).testTag(
when {
showSend -> "send_button"
isListening -> "mic_stop_button"
else -> "mic_button"
},
),
+ contentAlignment = Alignment.Center,
) {
Crossfade(
targetState =
@@ -468,15 +510,24 @@ fun ComposerToolbar(
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc),
+ tint = palette.onAction,
)
}
ActionGlyph.STOP -> {
- Icon(imageVector = Icons.Default.Stop, contentDescription = "Stop listening")
+ Icon(
+ imageVector = Icons.Default.Stop,
+ contentDescription = "Stop listening",
+ tint = if (isListening) MaterialTheme.colorScheme.onErrorContainer else palette.onAction,
+ )
}
ActionGlyph.VOICE -> {
- Icon(imageVector = Icons.Outlined.Mic, contentDescription = "Mic")
+ Icon(
+ imageVector = Icons.Outlined.Mic,
+ contentDescription = "Mic",
+ tint = palette.onAction,
+ )
}
}
}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt
new file mode 100644
index 00000000..9147e2fe
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt
@@ -0,0 +1,103 @@
+package com.m57.hermescontrol.ui.chat.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.m57.hermescontrol.R
+import com.m57.hermescontrol.ui.chat.PendingSend
+import com.m57.hermescontrol.ui.chat.PendingSendState
+
+@Composable
+fun PendingSendPanel(
+ sends: List,
+ mainTurnBusy: Boolean,
+ onSendNow: (String) -> Unit,
+) {
+ // Delivery receipts stay internal; only queued work and recovery need UI.
+ val visibleSends = sends.filter { it.state != PendingSendState.SENDING && it.state != PendingSendState.ACCEPTED }
+ if (visibleSends.isEmpty()) return
+ Surface(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ ) {
+ Column(modifier = Modifier.padding(8.dp)) {
+ Text(stringResource(R.string.chat_pending_sends), style = MaterialTheme.typography.titleSmall)
+ if (mainTurnBusy || visibleSends.any { it.state == PendingSendState.UNKNOWN }) {
+ Text(
+ stringResource(R.string.chat_pending_send_now_warning),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ LazyColumn(modifier = Modifier.heightIn(max = 170.dp)) {
+ items(visibleSends, key = { it.id }) { send ->
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ send.text.ifBlank { stringResource(R.string.chat_pending_attachments) },
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text =
+ when (send.state) {
+ PendingSendState.QUEUED -> stringResource(R.string.chat_pending_queued)
+ PendingSendState.PARKED -> stringResource(R.string.chat_pending_parked)
+ PendingSendState.SENDING -> stringResource(R.string.chat_pending_sending)
+ PendingSendState.ACCEPTED -> stringResource(R.string.chat_pending_accepted)
+ PendingSendState.UNKNOWN -> stringResource(R.string.chat_pending_unknown)
+ PendingSendState.REJECTED -> stringResource(R.string.chat_pending_rejected)
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (send.state in
+ setOf(
+ PendingSendState.QUEUED,
+ PendingSendState.PARKED,
+ PendingSendState.UNKNOWN,
+ PendingSendState.REJECTED,
+ PendingSendState.ACCEPTED,
+ )
+ ) {
+ TextButton(onClick = { onSendNow(send.id) }) {
+ Text(
+ stringResource(
+ if (mainTurnBusy) {
+ R.string.chat_busy_stop_and_send
+ } else if (send.state in
+ setOf(
+ PendingSendState.UNKNOWN,
+ PendingSendState.ACCEPTED,
+ )
+ ) {
+ R.string.chat_pending_send_again
+ } else {
+ R.string.chat_pending_send_now
+ },
+ ),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt
index 75344ee2..174605b6 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt
@@ -193,6 +193,8 @@ internal fun SettingsChatPage(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ChatSection(
+ busySendMode = state.busySendMode,
+ onBusySendModeChange = viewModel::onBusySendModeChange,
typingEffectEnabled = state.typingEffectEnabled,
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
typingEffectDelayMs = state.typingEffectDelayMs,
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt
index c1e38f2f..411f45bb 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.config.resolveBaseUrl
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SessionListCacheStore
+import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -37,6 +38,7 @@ data class SettingsUiState(
val testResult: String? = null,
val isSaved: Boolean = false,
val typingEffectEnabled: Boolean = false,
+ val busySendMode: BusySendMode = BusySendMode.CORRECT,
val typingEffectDelayMs: Int = 30,
val chatFontScale: Float = 1.0f,
val messageStatsEnabled: Boolean = false,
@@ -85,6 +87,7 @@ class SettingsViewModel(
val useDynamicColors = AuthManager.isUseDynamicColors()
val themePreset = AuthManager.getThemePreset()
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
+ val busySendMode = AuthManager.getBusySendMode()
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
val chatFontScale = AuthManager.getChatFontScale()
val messageStatsEnabled = AuthManager.isMessageStatsEnabled()
@@ -112,6 +115,7 @@ class SettingsViewModel(
useDynamicColors = useDynamicColors,
themePreset = themePreset,
typingEffectEnabled = typingEffectEnabled,
+ busySendMode = busySendMode,
typingEffectDelayMs = typingEffectDelayMs,
chatFontScale = chatFontScale,
messageStatsEnabled = messageStatsEnabled,
@@ -336,6 +340,11 @@ class SettingsViewModel(
AuthManager.setTypingEffectEnabled(enabled)
}
+ fun onBusySendModeChange(mode: BusySendMode) {
+ _uiState.update { it.copy(busySendMode = mode, isSaved = false) }
+ AuthManager.setBusySendMode(mode)
+ }
+
fun onTypingEffectDelayMsChange(delayMs: Int) {
_uiState.update { it.copy(typingEffectDelayMs = delayMs, isSaved = false) }
AuthManager.setTypingEffectDelayMs(delayMs)
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt
index 87d2e175..e59f37a6 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt
@@ -38,8 +38,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
+import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
+import com.m57.hermescontrol.ui.chat.label
import com.m57.hermescontrol.ui.settings.SectionCard
import kotlin.math.abs
import kotlin.math.roundToInt
@@ -215,6 +217,8 @@ internal fun ChatSection(
onTokensPerSecondChange: (Boolean) -> Unit = {},
showModelProvider: Boolean = false,
onShowModelProviderChange: (Boolean) -> Unit = {},
+ busySendMode: BusySendMode = BusySendMode.CORRECT,
+ onBusySendModeChange: (BusySendMode) -> Unit = {},
) {
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
val currentIndex =
@@ -223,6 +227,38 @@ internal fun ChatSection(
.takeIf { it >= 0 } ?: 1
SectionCard {
+ var busyModeExpanded by remember { mutableStateOf(false) }
+ Text(
+ text = stringResource(R.string.chat_busy_mode_title),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Text(
+ text = stringResource(R.string.chat_busy_mode_desc),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Box {
+ OutlinedButton(onClick = { busyModeExpanded = true }, modifier = Modifier.testTag("busy_send_default")) {
+ Text(busySendMode.label())
+ }
+ DropdownMenu(expanded = busyModeExpanded, onDismissRequest = { busyModeExpanded = false }) {
+ BusySendMode.entries.forEach { mode ->
+ DropdownMenuItem(
+ text = { Text(mode.label()) },
+ onClick = {
+ onBusySendModeChange(mode)
+ busyModeExpanded = false
+ },
+ )
+ }
+ }
+ }
+ Text(
+ text = stringResource(R.string.chat_busy_interrupt_warning),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 1f8b9619..858a5b53 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -1543,6 +1543,7 @@
تشغيل الفيديو
جارٍ تجهيز الجلسة…
الجلسة غير جاهزة — أعد المحاولة للمتابعة
+
إعداد %1$s
%1$d من %2$d
تم التفويض، لكن الأدوات غير متاحة
@@ -1556,4 +1557,25 @@
%1$s مطلوب
تعذر إرسال رد الإعداد. حاول مرة أخرى.
بانتظار Hermes…
-
\ No newline at end of file
+
+أثناء عمل الوكيل
+اختر كيفية وصول الرسالة الجديدة إلى المحادثة الحالية. تُرسل الرسائل بشكل عادي عند الخمول.
+تصحيح الدور الحالي
+الإرسال في الدور التالي
+توجيه عند النقطة الآمنة التالية
+مقاطعة قسرية ثم إرسال
+قد توقف المقاطعة القسرية الوكلاء الفرعيين. يسمح الانتظار والتوجيه باستمرار العمل الحالي.
+وضع إرسال هذه الرسالة
+يسري هذا الاختيار مرة واحدة فقط ولا يغير الإعداد الافتراضي المحفوظ.
+رسائل معلّقة
+في قائمة الانتظار على هذا الجهاز
+موقوفة بعد الإيقاف
+جارٍ الإرسال…
+قبلها الخادم؛ بانتظار ظهورها في السجل. قد تؤدي إعادة الإرسال إلى تكرارها.
+حالة التسليم غير معروفة؛ قد يؤدي الإرسال مجددًا إلى تكرارها
+لم تُسلّم؛ اختر الإرسال الآن لإعادة المحاولة
+إرسال الآن
+قد يقطع إرسال رسالة معلّقة الآن العمل الجاري ويوقف الوكلاء الفرعيين.
+إرسال مجددًا
+ستُرسل المرفقات مع هذه الرسالة.
+
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 1d7a72c3..d85ee3c7 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -1199,6 +1199,7 @@
비디오 재생
세션 준비 중…
세션이 준비되지 않았습니다. 다시 시도하세요
+
%1$s 설정
%2$d개 중 %1$d개
인증되었지만 도구를 사용할 수 없습니다
@@ -1239,4 +1240,25 @@
첨부 파일 오류: %1$s
활성 작업자
활성 작업자가 없습니다
-
\ No newline at end of file
+
+ 에이전트가 작업 중일 때
+ 새 메시지를 현재 채팅에 전달할 방식을 선택합니다. 유휴 상태에서는 일반 전송됩니다.
+ 현재 턴 수정
+ 다음 턴 대기열에 추가
+ 다음 안전 지점에서 안내
+ 강제 중단 후 전송
+ 강제 중단은 하위 에이전트를 중지할 수 있습니다. 대기열과 안내는 현재 작업을 계속합니다.
+ 이 메시지의 전송 방식
+ 이 선택은 한 번만 적용되며 저장된 기본값은 바뀌지 않습니다.
+ 대기 중인 메시지
+ 이 기기에서 대기 중
+ 중지 후 보류됨
+ 전송 중…
+ 서버에서 수락됨; 대화 기록 대기 중. 다시 보내면 중복될 수 있습니다.
+ 전달 여부를 알 수 없습니다. 다시 보내면 중복될 수 있습니다.
+ 전달되지 않았습니다. 지금 보내기를 눌러 재시도하세요.
+ 지금 보내기
+ 대기 중인 메시지를 지금 보내면 진행 중인 작업이 중단되고 하위 에이전트가 멈출 수 있습니다.
+ 다시 보내기
+ 첨부 파일도 함께 전송됩니다.
+
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index 71b28b63..43541c9f 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -1509,6 +1509,26 @@
%1$s为必填项
无法发送设置响应,请重试。
正在等待 Hermes…
+ 智能体工作时
+ 选择新消息如何送达当前聊天。空闲时正常发送。
+ 修正当前轮次
+ 排队等待下一轮
+ 在下一个安全点提供指导
+ 强制中断后发送
+ 强制中断可能停止子智能体。排队和指导会让当前工作继续。
+ 本条消息的发送方式
+ 此选择仅生效一次,不会更改保存的默认值。
+ 待发送消息
+ 已在本机排队
+ 停止后已暂停
+ 正在发送…
+ 服务器已接收,等待写入聊天记录。再次发送可能造成重复。
+ 送达状态未知;再次发送可能重复
+ 未送达;点按“立即发送”重试
+ 立即发送
+ 现在发送待处理消息可能会中断当前任务并停止子代理。
+ 再次发送
+ 附件将随这条消息发送。
时间线
回到最新
此窗口之前和之后还有更多消息。
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1074ec6e..f6d1261c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -194,6 +194,29 @@
Waiting for response\u2026
Queued \u2014 sends when agent is free\u2026
Type a message\u2026
+ While the agent is working
+ Choose how a new message reaches the current chat. Idle messages send normally.
+ Correct current turn
+ Queue next turn
+ Guide at next safe point
+ Hard interrupt, then send
+ Hard interrupt can stop child agents. Queue and guide let current work continue.
+ Queue
+ Stop & send
+ More
+ Send mode for this message
+ This choice applies once; the saved default stays the same.
+ Pending messages
+ Queued on this device
+ Parked after Stop
+ Sending\u2026
+ Accepted by server; awaiting transcript. Sending again may duplicate it.
+ Delivery unknown; sending again may duplicate it
+ Not delivered; choose Send now to retry
+ Send now
+ Sending a pending message now may interrupt active work and stop child agents.
+ Send again
+ Attachments will be sent with this message.
Execution Error: %1$s
Exit Code: %1$d
%1$d characters omitted from process output
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt
index bb26ccd7..7761d123 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt
@@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test
@@ -106,6 +107,23 @@ class ChatPersistenceRepositoryTest {
assertEquals(1000L, entity.timestamp)
}
+ @Test
+ fun deleteMessageRemovesOnlyUnconfirmedOptimisticRows() =
+ runTest {
+ repository.persistMessage(ChatMessage(id = "local", role = MessageRole.USER, content = "draft"), "s")
+ repository.persistMessage(
+ ChatMessage(id = "confirmed", role = MessageRole.USER, content = "history", restId = "rest-s-7"),
+ "s",
+ )
+
+ repository.deleteMessage("local")
+ repository.deleteMessage("confirmed")
+
+ val rows = repository.loadMessages("s")
+ assertFalse(rows.any { it.id == "local" })
+ assertEquals("confirmed", rows.single().id)
+ }
+
@Test
fun persistMessages_upsertsAllToDao() =
runTest {
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt
new file mode 100644
index 00000000..e11e1dc4
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt
@@ -0,0 +1,48 @@
+package com.m57.hermescontrol.ui.chat
+
+import com.m57.hermescontrol.data.model.BusySendMode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ChatQueuePresentationTest {
+ private val message = ChatMessage("queued-id", MessageRole.USER, "Later")
+
+ private fun pending(state: PendingSendState) =
+ PendingSend(message.id, "scope", "session", message.content, mode = BusySendMode.QUEUE, state = state)
+
+ @Test
+ fun queuedAndParkedMessagesRemainInternalUntilDispatch() {
+ val messages = listOf(message)
+ for (state in listOf(PendingSendState.QUEUED, PendingSendState.PARKED)) {
+ val receipts = listOf(pending(state))
+ assertTrue(messagesWithoutUnsentQueue(messages, receipts).isEmpty())
+ assertEquals(listOf(message), messages)
+ assertEquals(state, receipts.single().state)
+ }
+ }
+
+ @Test
+ fun queuedMessageAppearsOnceWhenSendingStarts() {
+ val messages = listOf(message)
+ assertTrue(messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))).isEmpty())
+ for (state in listOf(PendingSendState.SENDING, PendingSendState.ACCEPTED)) {
+ assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(state))))
+ }
+ }
+
+ @Test
+ fun ordinarySendAndOtherMessagesAreUnchanged() {
+ val other = ChatMessage("other", MessageRole.USER, "Send now")
+ val messages = listOf(other, message)
+ assertSame(messages, messagesWithoutUnsentQueue(messages, emptyList()))
+ assertEquals(listOf(other), messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
+ }
+
+ @Test
+ fun canonicalHistoryIsNeverHiddenByStaleQueueReceipt() {
+ val messages = listOf(message.copy(restId = "rest-session-12"))
+ assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
+ }
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt
new file mode 100644
index 00000000..89d3e122
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt
@@ -0,0 +1,207 @@
+package com.m57.hermescontrol.ui.chat
+
+import android.content.SharedPreferences
+import com.m57.hermescontrol.data.model.Attachment
+import com.m57.hermescontrol.data.model.BusySendMode
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ChatSendStoreTest {
+ private fun pending(
+ id: String,
+ text: String,
+ state: PendingSendState = PendingSendState.ACCEPTED,
+ ) = PendingSend(id, "scope", "session", text, mode = BusySendMode.QUEUE, state = state)
+
+ private fun mappedHistoryAliases(
+ incoming: List,
+ existing: List,
+ ): List =
+ incoming.zip(matchTranscriptMessages(incoming, existing)) { rest, match ->
+ match?.copy(restId = rest.canonicalRestId) ?: rest
+ }
+
+ @Test
+ fun durableAliasConfirmsAttachmentAndSteerNormalizedReceipt() {
+ val local = ChatMessage("send-1", MessageRole.USER, "See attachment")
+ val rest =
+ ChatMessage(
+ "rest-session-10",
+ MessageRole.USER,
+ """
+ [OUT-OF-BAND USER MESSAGE — direct steering]
+ @file:files/agent-vault/hermes/attachments/note.txt
+
+ See attachment
+ [/OUT-OF-BAND USER MESSAGE]
+ """.trimIndent(),
+ )
+
+ val confirmed =
+ pendingSendIdsConfirmedByDurableAliases(
+ mappedHistoryAliases(listOf(rest), listOf(local)),
+ listOf(pending("send-1", "See attachment")),
+ )
+
+ assertEquals(setOf("send-1"), confirmed)
+ }
+
+ @Test
+ fun durableAliasConsumesOnlyOneDuplicateReceiptOccurrence() {
+ val existing =
+ listOf(
+ ChatMessage("send-1", MessageRole.USER, "repeat"),
+ ChatMessage("send-2", MessageRole.USER, "repeat"),
+ )
+ val aliases =
+ mappedHistoryAliases(
+ listOf(ChatMessage("rest-session-10", MessageRole.USER, "repeat")),
+ existing,
+ )
+
+ val confirmed =
+ pendingSendIdsConfirmedByDurableAliases(
+ aliases,
+ listOf(pending("send-1", "repeat"), pending("send-2", "repeat")),
+ )
+
+ assertEquals(setOf("send-1"), confirmed)
+ }
+
+ @Test
+ fun repeatedHydrationDoesNotConfirmAnotherDuplicateReceipt() {
+ val aliases =
+ mappedHistoryAliases(
+ listOf(ChatMessage("rest-session-10", MessageRole.USER, "repeat")),
+ listOf(ChatMessage("send-1", MessageRole.USER, "repeat")),
+ )
+ val first =
+ pendingSendIdsConfirmedByDurableAliases(
+ aliases,
+ listOf(pending("send-1", "repeat"), pending("send-2", "repeat")),
+ )
+ val remaining = listOf(pending("send-2", "repeat")).filterNot { it.id in first }
+
+ assertTrue(pendingSendIdsConfirmedByDurableAliases(aliases, remaining).isEmpty())
+ }
+
+ @Test
+ fun durableAliasDoesNotConfirmQueuedOrParkedReceipts() {
+ val aliases =
+ listOf(
+ ChatMessage("queued", MessageRole.USER, "same", restId = "rest-session-10"),
+ ChatMessage("parked", MessageRole.USER, "same", restId = "rest-session-11"),
+ )
+
+ val confirmed =
+ pendingSendIdsConfirmedByDurableAliases(
+ aliases,
+ listOf(
+ pending("queued", "same", PendingSendState.QUEUED),
+ pending("parked", "same", PendingSendState.PARKED),
+ ),
+ )
+
+ assertTrue(confirmed.isEmpty())
+ }
+
+ @Test
+ fun promoteMovesTheChosenMessageAheadOfEarlierQueuedMessages() {
+ val store = ChatSendStore()
+ store.put(PendingSend("first", "scope", "session", "one", mode = BusySendMode.QUEUE))
+ store.put(PendingSend("second", "scope", "session", "two", mode = BusySendMode.QUEUE))
+
+ store.promote("second")
+
+ assertEquals(listOf("second", "first"), store.all().map { it.id })
+ }
+
+ @Test
+ fun recreationPreservesQueueAndQuarantinesAmbiguousSendsAcrossScopes() {
+ var saved: String? = null
+ val prefs = mockk()
+ val editor = mockk()
+ every { prefs.getString("rows", null) } answers { saved }
+ every { prefs.edit() } returns editor
+ every { editor.putString("rows", any()) } answers {
+ saved = secondArg()
+ editor
+ }
+ every { editor.commit() } returns true
+
+ val first = ChatSendStore(prefs)
+ first.put(
+ PendingSend(
+ "queued",
+ "connection-a",
+ "session-a",
+ "later",
+ attachments = listOf(Attachment("file:///private/photo", "photo.png", "image/png", 3)),
+ mode = BusySendMode.QUEUE,
+ ),
+ )
+ first.put(
+ PendingSend(
+ "sending",
+ "connection-b",
+ "session-b",
+ "maybe",
+ mode = BusySendMode.CORRECT,
+ state = PendingSendState.SENDING,
+ ),
+ )
+ first.park("connection-a", "session-a")
+
+ val restored = ChatSendStore(prefs)
+ assertEquals(PendingSendState.PARKED, restored.all().single { it.id == "queued" }.state)
+ assertEquals(PendingSendState.UNKNOWN, restored.all().single { it.id == "sending" }.state)
+ assertEquals("connection-a", restored.all().single { it.id == "queued" }.scope)
+ assertEquals(
+ "file:///private/photo",
+ restored
+ .all()
+ .single { it.id == "queued" }
+ .attachments
+ .single()
+ .uri,
+ )
+ assertEquals("session-b", restored.all().single { it.id == "sending" }.sessionId)
+
+ restored.remove("queued")
+ assertTrue(ChatSendStore(prefs).all().none { it.id == "queued" })
+ assertEquals(1, ChatSendStore(prefs).all().size)
+ }
+
+ @Test
+ fun recreationQuarantinesAcceptedReceiptWithoutAutomaticReplay() {
+ var saved: String? = null
+ val prefs = mockk()
+ val editor = mockk()
+ every { prefs.getString("rows", null) } answers { saved }
+ every { prefs.edit() } returns editor
+ every { editor.putString("rows", any()) } answers {
+ saved = secondArg()
+ editor
+ }
+ every { editor.commit() } returns true
+
+ ChatSendStore(prefs).put(
+ PendingSend(
+ "accepted",
+ "connection-a",
+ "session-a",
+ "possibly accepted",
+ mode = BusySendMode.QUEUE,
+ state = PendingSendState.ACCEPTED,
+ ),
+ )
+
+ val restored = ChatSendStore(prefs).all().single()
+
+ assertEquals(PendingSendState.UNKNOWN, restored.state)
+ assertEquals(0, restored.attempts)
+ }
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt
index daf6f132..3c0c3b57 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt
@@ -87,6 +87,10 @@ class ChatUpdateCommandTest {
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token"
+ every { AuthManager.getPinnedModels() } returns emptyList()
+ every { AuthManager.getBaseUrl() } returns "http://test.local/"
+ every { AuthManager.getSelectedProfileId() } returns null
+ every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false
@@ -148,7 +152,15 @@ class ChatUpdateCommandTest {
// threads — with a relaxed-mock app that NPEs on cacheDir and leaks
// an uncaught exception into the NEXT runTest class (CI flakes:
// UncaughtExceptionsBeforeTest).
- val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
+ val vm =
+ ChatViewModel(
+ app,
+ false,
+ fakeRepo,
+ FakeSlashUsageStore(),
+ ioDispatcher = testDispatcher,
+ sendStore = ChatSendStore(),
+ )
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -185,7 +197,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate triggers the REST action and starts the progress popup`() =
runTest {
- val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
+ val vm =
+ ChatViewModel(
+ app,
+ false,
+ fakeRepo,
+ FakeSlashUsageStore(),
+ ioDispatcher = testDispatcher,
+ sendStore = ChatSendStore(),
+ )
coEvery { mockApi.updateHermes() } returns
Response.success(ActionResponse(ok = true, name = "hermes-update"))
// One running poll, then the action exits — runTest's teardown
@@ -224,7 +244,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate settles on success when the action exits cleanly`() =
runTest {
- val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
+ val vm =
+ ChatViewModel(
+ app,
+ false,
+ fakeRepo,
+ FakeSlashUsageStore(),
+ ioDispatcher = testDispatcher,
+ sendStore = ChatSendStore(),
+ )
coEvery { mockApi.updateHermes() } returns
Response.success(ActionResponse(ok = true, name = "hermes-update"))
coEvery { mockApi.getActionStatus("hermes-update") } returns
@@ -252,7 +280,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate surfaces a rejected trigger in the popup`() =
runTest {
- val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
+ val vm =
+ ChatViewModel(
+ app,
+ false,
+ fakeRepo,
+ FakeSlashUsageStore(),
+ ioDispatcher = testDispatcher,
+ sendStore = ChatSendStore(),
+ )
coEvery { mockApi.updateHermes() } returns
Response.error(404, "no update endpoint".toResponseBody())
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
index 8bd9a1c1..57f46ef5 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat
import android.app.Application
import android.content.ContentResolver
import android.content.Intent
+import android.content.SharedPreferences
import android.net.Uri
import android.util.Log
import androidx.core.content.FileProvider
@@ -10,6 +11,7 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
+import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.PaginationInfo
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.model.SessionMessagesAroundPagination
@@ -46,6 +48,7 @@ import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
@@ -83,6 +86,7 @@ class ChatViewModelTest {
private lateinit var fakeRepo: FakeChatPersistenceRepository
private lateinit var fakeSlashUsageStore: FakeSlashUsageStore
private lateinit var attachmentCacheDir: java.io.File
+ private lateinit var attachmentFilesDir: java.io.File
/** Counter used to generate unique WS request IDs. */
private var reqCount = 0
@@ -279,7 +283,12 @@ class ChatViewModelTest {
java.nio.file.Files
.createTempDirectory("chat-attachments-test")
.toFile()
+ attachmentFilesDir =
+ java.nio.file.Files
+ .createTempDirectory("chat-send-files-test")
+ .toFile()
every { app.cacheDir } returns attachmentCacheDir
+ every { app.filesDir } returns attachmentFilesDir
fakeRepo = FakeChatPersistenceRepository()
fakeSlashUsageStore = FakeSlashUsageStore()
ActiveSessionHolder.set(null)
@@ -293,6 +302,7 @@ class ChatViewModelTest {
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow()
every { AuthManager.isTypingEffectEnabled() } returns true
+ every { AuthManager.getBusySendMode() } returns BusySendMode.CORRECT
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false
every { AuthManager.isUserMessageTokensEnabled() } returns true
@@ -357,6 +367,7 @@ class ChatViewModelTest {
fun tearDown() {
Dispatchers.resetMain()
attachmentCacheDir.deleteRecursively()
+ attachmentFilesDir.deleteRecursively()
unmockkAll()
}
@@ -366,9 +377,9 @@ class ChatViewModelTest {
private fun createViewModel(
startCleanup: Boolean = false,
historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = testDispatcher,
+ sendStore: ChatSendStore = ChatSendStore(),
): ChatViewModel =
- // Both dispatchers injected — a real ioDispatcher would race the
- // test scheduler (repo writes hop it) and shuffle RPC ordering.
+ // All injected dispatchers share the test scheduler so RPC ordering is deterministic.
ChatViewModel(
app,
startCleanup,
@@ -377,6 +388,7 @@ class ChatViewModelTest {
testDispatcher,
testDispatcher,
historyDispatcher,
+ sendStore,
)
/**
@@ -389,8 +401,9 @@ class ChatViewModelTest {
private suspend fun TestScope.createViewModelWithSession(
startCleanup: Boolean = false,
historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = testDispatcher,
+ sendStore: ChatSendStore = ChatSendStore(),
): Pair {
- val viewModel = createViewModel(startCleanup, historyDispatcher)
+ val viewModel = createViewModel(startCleanup, historyDispatcher, sendStore)
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
@@ -828,7 +841,7 @@ class ChatViewModelTest {
advanceUntilIdle()
// 3. WS returns interrupt success -> adds "Session interrupted"
- mockEventsFlow.emit(WsEvent.RpcResult("interrupt-req-1", mapOf("ok" to true)))
+ mockEventsFlow.emit(WsEvent.RpcResult("interrupt-req-1", mapOf("status" to "interrupted")))
advanceUntilIdle()
// 4. Server transcript sync arrives with newer turns
@@ -1814,6 +1827,36 @@ class ChatViewModelTest {
// ── Clarify tests ────────────────────────────────────────────────────────
+ @Test
+ fun mainTurnBusyTracksRuntimeEventsNotClarificationTyping() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ assertFalse(viewModel.uiState.value.isMainTurnBusy)
+
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ assertTrue(viewModel.uiState.value.isMainTurnBusy)
+
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+ assertFalse(viewModel.uiState.value.isMainTurnBusy)
+
+ mockEventsFlow.emit(
+ WsEvent.ClarifyRequest(
+ text = "Continue?",
+ options = listOf("Yes"),
+ clarifyId = "clarify-busy-state",
+ sessionId = sessionId,
+ ),
+ )
+ advanceUntilIdle()
+ viewModel.respondToClarify("Yes")
+ advanceUntilIdle()
+
+ assertTrue(viewModel.uiState.value.isAgentTyping)
+ assertFalse(viewModel.uiState.value.isMainTurnBusy)
+ }
+
@Test
fun testClarifyRequestAndRespond() =
runTest {
@@ -2295,20 +2338,16 @@ class ChatViewModelTest {
size = MAX_CHAT_ATTACHMENT_BYTES + 1,
)
- viewModel.sendMessage("Inspect this archive")
+ assertFalse(viewModel.sendMessage("Inspect this archive"))
advanceUntilIdle()
assertEquals(2, viewModel.uiState.value.pendingAttachments.size)
- assertEquals("Inspect this archive", viewModel.uiState.value.composerTextToRestore)
+ assertNull(viewModel.uiState.value.composerTextToRestore)
assertTrue(
viewModel.uiState.value.errorMessage
?.contains("too large") == true,
)
verify(exactly = 0) { contentResolver.openInputStream(any()) }
-
- viewModel.consumeComposerTextRestore()
- advanceUntilIdle()
- assertNull(viewModel.uiState.value.composerTextToRestore)
}
@Test
@@ -2460,11 +2499,24 @@ class ChatViewModelTest {
val (viewModel, _) = createViewModelWithSession()
val uriString = "content://test/note"
val uri = mockk()
+ val snapshotUri = mockk()
val resolver = mockk()
+ lateinit var snapshotFile: java.io.File
mockkStatic(Uri::class)
every { Uri.parse(uriString) } returns uri
+ every { Uri.fromFile(any()) } answers {
+ snapshotFile = firstArg()
+ snapshotUri
+ }
+ every { snapshotUri.toString() } returns "file://private-note-copy"
+ every { Uri.parse("file://private-note-copy") } returns snapshotUri
every { app.contentResolver } returns resolver
every { resolver.openInputStream(uri) } answers { "hello".byteInputStream() }
+ every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() }
+ mockkConstructor(android.util.Base64OutputStream::class)
+ every { anyConstructed().write(any(), any(), any()) } returns
+ Unit
+ every { anyConstructed().close() } returns Unit
every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns
CompletableDeferred(
buildJsonObject {
@@ -2786,7 +2838,7 @@ class ChatViewModelTest {
viewModel.uiState.value.messages[1]
.role,
)
- assertTrue(viewModel.uiState.value.isAgentTyping)
+ assertTrue(viewModel.uiState.value.isSending)
assertEquals(sessionId, ActiveSessionHolder.resolveStoredSessionId(sessionId))
verify { HermesWsClient.sendMessage(sessionId, "Hello Hermes", any()) }
@@ -2800,6 +2852,8 @@ class ChatViewModelTest {
// First send starts streaming
viewModel.sendMessage("Hello Hermes")
advanceUntilIdle()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
assertTrue(viewModel.uiState.value.isAgentTyping)
// Second send while streaming triggers redirect
@@ -2809,6 +2863,1834 @@ class ChatViewModelTest {
verify { HermesWsClient.sendRedirect(sessionId, "Wait, correction", any()) }
}
+ @Test
+ fun rejectedRedirectRemovesGhostAndQueuesRecoverableText() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ var redirectId = ""
+ every { HermesWsClient.sendRedirect(sessionId, any(), any()) } answers {
+ redirectId = "rejected-redirect"
+ arg<((String) -> Unit)?>(2)?.invoke(redirectId)
+ redirectId
+ }
+
+ viewModel.sendMessage("First turn")
+ advanceUntilIdle()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.sendMessage("Correction")
+ advanceUntilIdle()
+ mockEventsFlow.emit(WsEvent.RpcResult(redirectId, mapOf("status" to "rejected")))
+ advanceUntilIdle()
+
+ assertFalse(
+ viewModel.uiState.value.messages
+ .any { it.content == "Correction" },
+ )
+ assertFalse(fakeRepo.loadMessages(sessionId).any { it.content == "Correction" })
+ assertTrue(
+ viewModel.uiState.value.pendingSends
+ .any { it.text == "Correction" },
+ )
+ assertTrue(viewModel.uiState.value.isAgentTyping)
+ }
+
+ @Test
+ fun rejectedRedirectFallsBackToQueueAndRestHistoryRecoversOneUserRow() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ var redirectId = ""
+ var queuedId = ""
+ var historyHasCorrection = false
+ every { HermesWsClient.sendRedirect(sessionId, any(), any()) } answers {
+ redirectId = "redirect-for-history"
+ arg<((String) -> Unit)?>(2)?.invoke(redirectId)
+ redirectId
+ }
+ every { HermesWsClient.sendMessage(sessionId, "Correction", any(), true) } answers {
+ queuedId = "queued-for-history"
+ arg<((String) -> Unit)?>(2)?.invoke(queuedId)
+ queuedId
+ }
+ coEvery {
+ ApiClient.hermesApi.getSessionMessages(sessionId, any(), any(), any(), any())
+ } answers {
+ retrofit2.Response.success(
+ SessionMessagesResponse(
+ messages =
+ if (historyHasCorrection) {
+ listOf(
+ SessionMessage(
+ id = 12,
+ role = "user",
+ content = JsonPrimitive("Correction"),
+ timestamp = JsonPrimitive(System.currentTimeMillis()),
+ ),
+ )
+ } else {
+ emptyList()
+ },
+ pagination = PaginationInfo(order = "latest"),
+ ),
+ )
+ }
+
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.sendMessage("Correction")
+ advanceUntilIdle()
+ mockEventsFlow.emit(WsEvent.RpcResult(redirectId, mapOf("status" to "rejected")))
+ advanceUntilIdle()
+ assertFalse(
+ viewModel.uiState.value.messages
+ .any { it.content == "Correction" },
+ )
+
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+ assertEquals("queued-for-history", queuedId)
+ mockEventsFlow.emit(WsEvent.RpcResult(queuedId, mapOf("status" to "queued")))
+ advanceUntilIdle()
+ assertEquals(
+ PendingSendState.ACCEPTED,
+ viewModel.uiState.value.pendingSends
+ .single()
+ .state,
+ )
+
+ historyHasCorrection = true
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ mockEventsFlow.emit(WsEvent.MessageComplete("Answered", sessionId))
+ advanceUntilIdle()
+ assertTrue(
+ viewModel.uiState.value.pendingSends
+ .isEmpty(),
+ )
+ assertEquals(
+ 1,
+ viewModel.uiState.value.messages
+ .count { it.content == "Correction" },
+ )
+ assertTrue(
+ viewModel.uiState.value.messages
+ .any { it.content == "Correction" && it.canonicalRestId != null },
+ )
+ }
+
+ @Test
+ fun busyQueueWaitsForCompletionAndUsesQueuedSubmit() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+
+ assertTrue(viewModel.sendMessage("Next turn", BusySendMode.QUEUE))
+ advanceUntilIdle()
+ assertEquals(
+ PendingSendState.QUEUED,
+ viewModel.uiState.value.pendingSends
+ .single()
+ .state,
+ )
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Next turn", any(), any()) }
+
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Next turn", any(), true) }
+ }
+
+ @Test
+ fun queueTapAfterTurnCompletionSubmitsNormally() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+
+ assertTrue(viewModel.sendMessage("Late queue tap", BusySendMode.QUEUE))
+ advanceUntilIdle()
+
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Late queue tap", any(), false) }
+ assertFalse(
+ viewModel.uiState.value.pendingSends
+ .any { it.text == "Late queue tap" && it.state == PendingSendState.QUEUED },
+ )
+ }
+
+ @Test
+ fun queueDrainKeepsOwnershipWhileFirstReceiptPersistenceIsSuspended() =
+ runTest {
+ fakeRepo = spyk(fakeRepo)
+ val releasePersistence = CompletableDeferred()
+ var holdPersistence = false
+ var preparationCount = 0
+ coEvery { fakeRepo.persistMessage(match { it.content == "FIFO first" }, any()) } coAnswers {
+ if (holdPersistence) {
+ preparationCount++
+ releasePersistence.await()
+ }
+ Unit
+ }
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.sendMessage("FIFO first", BusySendMode.QUEUE)
+ viewModel.sendMessage("FIFO second", BusySendMode.QUEUE)
+ advanceUntilIdle()
+
+ holdPersistence = true
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+ assertEquals(1, preparationCount)
+ // A duplicate completion must not start another drain during pre-enqueue IO.
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done again", sessionId))
+ advanceUntilIdle()
+ val preparationsBeforeRelease = preparationCount
+ releasePersistence.complete(Unit)
+ advanceUntilIdle()
+
+ assertEquals(1, preparationsBeforeRelease)
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "FIFO first", any(), true) }
+ verify(exactly = 0) { HermesWsClient.sendMessage(sessionId, "FIFO second", any(), any()) }
+ }
+
+ @Test
+ fun slashQueueSharesDurableLocalQueueWhileBusy() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.sendMessage("/queue after tools")
+ advanceUntilIdle()
+ assertEquals(
+ PendingSendState.QUEUED,
+ viewModel.uiState.value.pendingSends
+ .single()
+ .state,
+ )
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "after tools", any(), any()) }
+
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "after tools", any(), true) }
+ }
+
+ @Test
+ fun busyAttachmentIsSnapshottedAndQueuedWithoutRedirect() =
+ runTest {
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val sourceUri = mockk()
+ val snapshotUri = mockk()
+ val resolver = mockk()
+ lateinit var snapshotFile: java.io.File
+ mockkStatic(Uri::class)
+ every { Uri.parse("content://queued/photo") } returns sourceUri
+ every { Uri.parse("file://queued-copy") } returns snapshotUri
+ every { Uri.fromFile(any()) } answers {
+ snapshotFile = firstArg()
+ snapshotUri
+ }
+ every { snapshotUri.toString() } returns "file://queued-copy"
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() }
+ every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() }
+ io.mockk.mockkConstructor(android.util.Base64OutputStream::class)
+ every {
+ anyConstructed().write(any(), any(), any())
+ } returns Unit
+ every { anyConstructed().close() } returns Unit
+ every { HermesWsClient.request(WsMethods.IMAGE_ATTACH_BYTES, any(), any()) } returns
+ CompletableDeferred(mapOf("attached" to true))
+
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.addAttachment("content://queued/photo", "photo.png", "image/png", 3)
+ viewModel.sendMessage("See photo")
+ assertTrue("The receipt must not exist before the private snapshot completes", store.all().isEmpty())
+ advanceUntilIdle()
+ assertEquals(
+ PendingSendState.QUEUED,
+ viewModel.uiState.value.pendingSends
+ .single()
+ .state,
+ )
+ assertTrue(snapshotFile.exists())
+ assertEquals(
+ "file://queued-copy",
+ store
+ .all()
+ .single()
+ .attachments
+ .single()
+ .uri,
+ )
+ verify(exactly = 0) { HermesWsClient.sendRedirect(any(), any(), any()) }
+
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "See photo", any(), true) }
+ }
+
+ @Test
+ fun manualAttachmentRetryCannotOverlapActiveQueuePreparation() =
+ runTest {
+ fakeRepo = spyk(fakeRepo)
+ val releasePersistence = CompletableDeferred()
+ var holdPersistence = false
+ var preparations = 0
+ coEvery { fakeRepo.persistMessage(match { it.content == "Queued attachment" }, any()) } coAnswers {
+ if (holdPersistence) {
+ preparations++
+ releasePersistence.await()
+ }
+ Unit
+ }
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val sourceUri = mockk()
+ val snapshotUri = mockk()
+ val resolver = mockk()
+ lateinit var snapshot: java.io.File
+ mockkStatic(Uri::class)
+ every { Uri.parse("content://queue/overlap") } returns sourceUri
+ every { Uri.fromFile(any()) } answers {
+ snapshot = firstArg()
+ snapshotUri
+ }
+ every { snapshotUri.toString() } returns "file://queue-overlap"
+ every { Uri.parse("file://queue-overlap") } returns snapshotUri
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() }
+ every { resolver.openInputStream(snapshotUri) } answers { snapshot.inputStream() }
+ mockkConstructor(android.util.Base64OutputStream::class)
+ every { anyConstructed().write(any(), any(), any()) } returns
+ Unit
+ every { anyConstructed().close() } returns Unit
+ every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns
+ CompletableDeferred(mapOf("ref_text" to "@file:overlap.txt"))
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.addAttachment("content://queue/overlap", "overlap.txt", "text/plain", 3)
+ viewModel.sendMessage("Queued attachment", BusySendMode.QUEUE)
+ advanceUntilIdle()
+ val receipt = store.all().single()
+ holdPersistence = true
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+ assertEquals(1, preparations)
+
+ viewModel.sendQueuedNow(receipt.id)
+ viewModel.sendQueuedNow(receipt.id)
+ advanceUntilIdle()
+ val preparationsBeforeRelease = preparations
+ releasePersistence.complete(Unit)
+ advanceUntilIdle()
+
+ assertEquals(1, preparationsBeforeRelease)
+ assertEquals(1, store.all().size)
+ assertEquals(PendingSendState.SENDING, store.all().single().state)
+ verify(exactly = 1) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) }
+ verify(exactly = 1) {
+ HermesWsClient.sendMessage(sessionId, "@file:overlap.txt\n\nQueued attachment", any(), true)
+ }
+ verify(exactly = 0) {
+ HermesWsClient.sendMessage(sessionId, "@file:overlap.txt\n\nQueued attachment", any(), false)
+ }
+ }
+
+ @Test
+ fun idleAttachmentReceiptIsCommittedOnlyWithPrivateSnapshotUri() =
+ runTest {
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val sourceUri = mockk()
+ val snapshotUri = mockk()
+ val resolver = mockk()
+ lateinit var snapshotFile: java.io.File
+ mockkStatic(Uri::class)
+ every { Uri.parse("content://idle/photo") } returns sourceUri
+ every { Uri.fromFile(any()) } answers {
+ snapshotFile = firstArg()
+ snapshotUri
+ }
+ every { snapshotUri.toString() } returns "file://private-idle-copy"
+ every { Uri.parse("file://private-idle-copy") } returns snapshotUri
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() }
+ every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() }
+ mockkConstructor(android.util.Base64OutputStream::class)
+ every { anyConstructed().write(any(), any(), any()) } returns
+ Unit
+ every { anyConstructed().close() } returns Unit
+ every { HermesWsClient.request(WsMethods.IMAGE_ATTACH_BYTES, any(), any()) } returns
+ CompletableDeferred(mapOf("attached" to true))
+
+ viewModel.addAttachment("content://idle/photo", "photo.png", "image/png", 3)
+ assertTrue(viewModel.sendMessage("Idle attachment"))
+ assertTrue(store.all().isEmpty())
+ advanceUntilIdle()
+
+ assertTrue(snapshotFile.exists())
+ assertEquals(
+ "file://private-idle-copy",
+ store
+ .all()
+ .single()
+ .attachments
+ .single()
+ .uri,
+ )
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Idle attachment", any(), false) }
+ }
+
+ @Test
+ fun interruptAttachmentReceiptIsPrivateBeforeInterruptRequest() =
+ runTest {
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val sourceUri = mockk()
+ val snapshotUri = mockk()
+ val resolver = mockk()
+ mockkStatic(Uri::class)
+ every { Uri.parse("content://interrupt/note") } returns sourceUri
+ every { Uri.fromFile(any()) } returns snapshotUri
+ every { snapshotUri.toString() } returns "file://private-interrupt-copy"
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(4, 5, 6).inputStream() }
+
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.addAttachment("content://interrupt/note", "note.txt", "text/plain", 3)
+ assertTrue(viewModel.sendMessage("Replace with file", BusySendMode.INTERRUPT))
+ assertTrue(store.all().isEmpty())
+ advanceUntilIdle()
+
+ assertEquals(
+ "file://private-interrupt-copy",
+ store
+ .all()
+ .single()
+ .attachments
+ .single()
+ .uri,
+ )
+ verify(exactly = 1) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replace with file", any(), any()) }
+ }
+
+ @Test
+ fun slowAttachmentPrecommitKeepsLaterTextBehindItsReceipt() =
+ runTest {
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val sourceUri = mockk()
+ val snapshotUri = mockk()
+ val resolver = mockk()
+ lateinit var snapshotFile: java.io.File
+ mockkStatic(Uri::class)
+ every { Uri.parse("content://slow/first") } returns sourceUri
+ every { Uri.fromFile(any()) } answers {
+ snapshotFile = firstArg()
+ snapshotUri
+ }
+ every { snapshotUri.toString() } returns "file://private-first-copy"
+ every { Uri.parse("file://private-first-copy") } returns snapshotUri
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(sourceUri) } answers {
+ assertTrue(viewModel.sendMessage("Later text"))
+ byteArrayOf(1, 2, 3).inputStream()
+ }
+ every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() }
+ mockkConstructor(android.util.Base64OutputStream::class)
+ every { anyConstructed().write(any(), any(), any()) } returns
+ Unit
+ every { anyConstructed().close() } returns Unit
+ every { HermesWsClient.request(WsMethods.IMAGE_ATTACH_BYTES, any(), any()) } returns
+ CompletableDeferred(mapOf("attached" to true))
+
+ viewModel.addAttachment("content://slow/first", "first.png", "image/png", 3)
+ assertTrue(viewModel.sendMessage("First attachment"))
+ advanceUntilIdle()
+
+ assertEquals(listOf("First attachment", "Later text"), store.all().map { it.text })
+ assertTrue(store.all().zipWithNext().all { (a, b) -> a.createdAt < b.createdAt })
+ assertEquals(PendingSendState.SENDING, store.all()[0].state)
+ assertEquals(PendingSendState.QUEUED, store.all()[1].state)
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "First attachment", any(), false) }
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Later text", any(), any()) }
+ }
+
+ @Test
+ fun busyAttachmentSnapshotFailureRestoresDraftWithoutReceiptOrGhost() =
+ runTest {
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val sourceUri = mockk()
+ val resolver = mockk()
+ mockkStatic(Uri::class)
+ every { Uri.parse("content://queued/missing") } returns sourceUri
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(sourceUri) } returns null
+
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.addAttachment("content://queued/missing", "missing.txt", "text/plain", 4)
+
+ assertTrue(viewModel.sendMessage("Keep my draft", BusySendMode.QUEUE))
+ advanceUntilIdle()
+
+ assertTrue(store.all().isEmpty())
+ assertEquals("Keep my draft", viewModel.uiState.value.composerTextToRestore)
+ assertEquals(
+ "content://queued/missing",
+ viewModel.uiState.value.pendingAttachments
+ .single()
+ .uri,
+ )
+ assertFalse(
+ viewModel.uiState.value.messages
+ .any { it.content == "Keep my draft" },
+ )
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Keep my draft", any(), any()) }
+ }
+
+ @Test
+ fun busyAttachmentSnapshotFinishingAfterSessionSwitchDoesNotCommitOrSubmit() =
+ runTest {
+ stubSession456Rests(success = true)
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val sourceUri = mockk()
+ val snapshotUri = mockk()
+ val resolver = mockk()
+ lateinit var stagedFile: java.io.File
+ mockkStatic(Uri::class)
+ every { Uri.parse("content://queued/session-switch") } returns sourceUri
+ every { Uri.fromFile(any()) } answers {
+ stagedFile = firstArg()
+ snapshotUri
+ }
+ every { snapshotUri.toString() } returns "file://stale-copy"
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(sourceUri) } answers {
+ viewModel.switchSession("session-456")
+ byteArrayOf(1, 2, 3).inputStream()
+ }
+
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.addAttachment(
+ "content://queued/session-switch",
+ "switch.txt",
+ "text/plain",
+ 3,
+ )
+ assertTrue(viewModel.sendMessage("Old session queued", BusySendMode.QUEUE))
+ assertFalse(
+ viewModel.uiState.value.messages
+ .any { it.content == "Old session queued" },
+ )
+
+ advanceUntilIdle()
+
+ assertEquals("session-456", viewModel.uiState.value.currentSessionId)
+ assertTrue(store.all().isEmpty())
+ assertFalse(stagedFile.exists())
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Old session queued", any(), any()) }
+ }
+
+ @Test
+ fun idleIgnoresBusyModeAndSubmitsNormally() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ assertTrue(viewModel.sendMessage("Idle", BusySendMode.GUIDE))
+ advanceUntilIdle()
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Idle", any(), false) }
+ verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_STEER, any(), any()) }
+ }
+
+ @Test
+ fun subagentActivityAloneDoesNotTurnAnIdleMainSessionBusy() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(
+ WsEvent.SubagentEvent(
+ type = "subagent.progress",
+ payload = mapOf("subagent_id" to "child-1", "status" to "running"),
+ sessionId = sessionId,
+ ),
+ )
+ advanceUntilIdle()
+ viewModel.sendMessage("Main prompt", BusySendMode.GUIDE)
+ advanceUntilIdle()
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Main prompt", any(), false) }
+ verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_STEER, any(), any()) }
+ }
+
+ @Test
+ fun toolStartMarksMainTurnBusyEvenWithoutMessageStart() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(WsEvent.ToolStart("shell", emptyMap(), sessionId))
+ advanceUntilIdle()
+ viewModel.sendMessage("Guide", BusySendMode.GUIDE)
+ advanceUntilIdle()
+ verify(exactly = 1) { HermesWsClient.send(WsMethods.SESSION_STEER, any(), any()) }
+ verify(exactly = 0) { HermesWsClient.sendMessage(sessionId, "Guide", any(), any()) }
+ }
+
+ @Test
+ fun refreshSettingsPicksUpChangedSavedBusyMode() =
+ runTest {
+ var savedMode = BusySendMode.CORRECT
+ every { AuthManager.getBusySendMode() } answers { savedMode }
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+ assertEquals(BusySendMode.CORRECT, viewModel.uiState.value.busySendMode)
+
+ savedMode = BusySendMode.GUIDE
+ viewModel.refreshSettings()
+ advanceUntilIdle()
+ assertEquals(BusySendMode.GUIDE, viewModel.uiState.value.busySendMode)
+ }
+
+ @Test
+ fun rapidDuplicateTapCreatesOneReceiptAndOneRpc() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ assertTrue(viewModel.sendMessage("One tap"))
+ assertFalse(viewModel.sendMessage("One tap"))
+ advanceUntilIdle()
+ assertEquals(1, viewModel.uiState.value.pendingSends.size)
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "One tap", any(), any()) }
+ }
+
+ @Test
+ fun stalePromptAckAfterSessionSwitchKeepsOldReceiptAndCurrentSessionClear() =
+ runTest {
+ stubSession456Rests(success = true)
+ val sendStore = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore)
+ val requestId = "old-prompt"
+ every { HermesWsClient.sendMessage(sessionId, "Old session", any(), any()) } answers {
+ arg<((String) -> Unit)?>(2)?.invoke(requestId)
+ requestId
+ }
+ viewModel.sendMessage("Old session")
+ advanceUntilIdle()
+ viewModel.switchSession("session-456")
+ advanceUntilIdle()
+
+ mockEventsFlow.emit(WsEvent.RpcResult(requestId, mapOf("status" to "streaming")))
+ advanceUntilIdle()
+ assertTrue(
+ viewModel.uiState.value.pendingSends
+ .isEmpty(),
+ )
+ assertEquals(PendingSendState.UNKNOWN, sendStore.all().single().state)
+ }
+
+ @Test
+ fun busyGuideUsesSteerAndUnsupportedFallsBackToQueueWithoutInterrupt() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+
+ viewModel.sendMessage("Keep the tests", BusySendMode.GUIDE)
+ advanceUntilIdle()
+ val id = sentRequestMethods.last { it.first == WsMethods.SESSION_STEER }.second
+ mockEventsFlow.emit(WsEvent.RpcError(id, JsonRpcError(4010, "steer unsupported")))
+ advanceUntilIdle()
+
+ assertEquals(
+ PendingSendState.QUEUED,
+ viewModel.uiState.value.pendingSends
+ .single()
+ .state,
+ )
+ verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
+ }
+
+ @Test
+ fun interruptWithUnknownDeliveryQueuesWithoutInterrupting() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ every { HermesWsClient.sendMessage(sessionId, "Uncertain", any(), false) } answers {
+ arg<((String) -> Unit)?>(2)?.invoke("uncertain-submit")
+ "uncertain-submit"
+ }
+
+ viewModel.sendMessage("Uncertain")
+ advanceUntilIdle()
+ viewModel.expireOutgoingRequest("uncertain-submit")
+ advanceUntilIdle()
+
+ viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT)
+ advanceUntilIdle()
+
+ val replacement =
+ viewModel.uiState.value.pendingSends
+ .single { it.text == "Replacement" }
+ assertEquals(BusySendMode.QUEUE, replacement.mode)
+ assertEquals(PendingSendState.QUEUED, replacement.state)
+ assertEquals(
+ PendingSendState.UNKNOWN,
+ viewModel.uiState.value.pendingSends
+ .single { it.text == "Uncertain" }
+ .state,
+ )
+ verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) }
+ }
+
+ @Test
+ fun interruptWithAcceptedDeliveryQueuesWithoutInterrupting() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ every { HermesWsClient.sendMessage(sessionId, "Accepted", any(), false) } answers {
+ arg<((String) -> Unit)?>(2)?.invoke("accepted-submit")
+ "accepted-submit"
+ }
+
+ viewModel.sendMessage("Accepted")
+ advanceUntilIdle()
+ mockEventsFlow.emit(WsEvent.RpcResult("accepted-submit", mapOf("status" to "streaming")))
+ advanceUntilIdle()
+
+ viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT)
+ advanceUntilIdle()
+
+ val replacement =
+ viewModel.uiState.value.pendingSends
+ .single { it.text == "Replacement" }
+ assertEquals(BusySendMode.QUEUE, replacement.mode)
+ assertEquals(PendingSendState.QUEUED, replacement.state)
+ assertEquals(
+ PendingSendState.ACCEPTED,
+ viewModel.uiState.value.pendingSends
+ .single { it.text == "Accepted" }
+ .state,
+ )
+ verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) }
+ }
+
+ @Test
+ fun interruptAfterCompletedTurnSubmitsNormally() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+
+ viewModel.sendMessage("Fresh turn", BusySendMode.INTERRUPT)
+ advanceUntilIdle()
+
+ verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Fresh turn", any(), false) }
+ }
+
+ @Test
+ fun hardInterruptWaitsForAckBeforeSubmitting() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+
+ viewModel.sendMessage("Urgent", BusySendMode.INTERRUPT)
+ advanceUntilIdle()
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Urgent", any(), any()) }
+ val id = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second
+ mockEventsFlow.emit(WsEvent.RpcResult(id, mapOf("status" to "interrupted")))
+ advanceUntilIdle()
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Urgent", any(), true) }
+ }
+
+ @Test
+ fun notInterruptedQueuesKnownUnsentReplacementAndSubmitsExactlyOnce() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+
+ viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT)
+ advanceUntilIdle()
+ val interruptId = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second
+
+ mockEventsFlow.emit(WsEvent.RpcResult(interruptId, mapOf("status" to "not_interrupted")))
+ advanceUntilIdle()
+ mockEventsFlow.emit(WsEvent.RpcResult(interruptId, mapOf("status" to "not_interrupted")))
+ advanceUntilIdle()
+
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Replacement", any(), true) }
+ assertEquals(
+ PendingSendState.SENDING,
+ viewModel.uiState.value.pendingSends
+ .single { it.text == "Replacement" }
+ .state,
+ )
+ }
+
+ @Test
+ fun lateNotInterruptedDoesNotClearOrRaceANewerSameSessionTurn() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT)
+ advanceUntilIdle()
+ val interruptId = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second
+
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ mockEventsFlow.emit(WsEvent.MessageToken("new turn", sessionId))
+ advanceUntilIdle()
+ mockEventsFlow.emit(WsEvent.RpcResult(interruptId, mapOf("status" to "not_interrupted")))
+ advanceUntilIdle()
+
+ assertTrue(viewModel.uiState.value.isMainTurnBusy)
+ assertEquals(
+ "new turn",
+ viewModel.streamingState.value.streamingMessage
+ ?.content,
+ )
+ assertEquals(
+ PendingSendState.QUEUED,
+ viewModel.uiState.value.pendingSends
+ .single { it.text == "Replacement" }
+ .state,
+ )
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) }
+
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Replacement", any(), true) }
+ }
+
+ @Test
+ fun lateInterruptedDoesNotClearOrDispatchAcrossANewerSameSessionTurn() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT)
+ advanceUntilIdle()
+ val interruptId = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second
+
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ mockEventsFlow.emit(WsEvent.MessageToken("new turn", sessionId))
+ advanceUntilIdle()
+ mockEventsFlow.emit(WsEvent.RpcResult(interruptId, mapOf("status" to "interrupted")))
+ advanceUntilIdle()
+
+ assertTrue(viewModel.uiState.value.isMainTurnBusy)
+ assertEquals(
+ "new turn",
+ viewModel.streamingState.value.streamingMessage
+ ?.content,
+ )
+ assertEquals(
+ PendingSendState.QUEUED,
+ viewModel.uiState.value.pendingSends
+ .single { it.text == "Replacement" }
+ .state,
+ )
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) }
+
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Replacement", any(), true) }
+ }
+
+ @Test
+ fun attachmentInterruptUsesTurnCapturedAtClickAndQueuesIfCopyOutlivesIt() =
+ runTest {
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val sourceUri = mockk()
+ val snapshotUri = mockk()
+ val resolver = mockk()
+ lateinit var snapshotFile: java.io.File
+ mockkStatic(Uri::class)
+ every { Uri.parse("content://interrupt/slow") } returns sourceUri
+ every { Uri.fromFile(any()) } answers {
+ snapshotFile = firstArg()
+ snapshotUri
+ }
+ every { snapshotUri.toString() } returns "file://private-slow-copy"
+ every { Uri.parse("file://private-slow-copy") } returns snapshotUri
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(sourceUri) } answers {
+ mockEventsFlow.tryEmit(WsEvent.MessageStart(sessionId))
+ byteArrayOf(1, 2, 3).inputStream()
+ }
+ every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() }
+ mockkConstructor(android.util.Base64OutputStream::class)
+ every { anyConstructed().write(any(), any(), any()) } returns
+ Unit
+ every { anyConstructed().close() } returns Unit
+ every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns
+ CompletableDeferred(mapOf("ref_text" to "@file:slow.txt"))
+
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.addAttachment("content://interrupt/slow", "slow.txt", "text/plain", 3)
+ assertTrue(viewModel.sendMessage("Captured turn", BusySendMode.INTERRUPT))
+ advanceUntilIdle()
+
+ val receipt = store.all().single()
+ assertEquals(PendingSendState.QUEUED, receipt.state)
+ assertEquals(BusySendMode.QUEUE, receipt.mode)
+ verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
+
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+ verify(
+ exactly = 1,
+ ) { HermesWsClient.sendMessage(sessionId, "@file:slow.txt\n\nCaptured turn", any(), true) }
+ }
+
+ @Test
+ fun stopParksLocalQueueAndSendNowUnparksIt() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.sendMessage("Later", BusySendMode.QUEUE)
+ advanceUntilIdle()
+ val queuedId =
+ viewModel.uiState.value.pendingSends
+ .single()
+ .id
+
+ viewModel.interruptSession()
+ advanceUntilIdle()
+ assertEquals(
+ PendingSendState.PARKED,
+ viewModel.uiState.value.pendingSends
+ .single()
+ .state,
+ )
+ val stopId = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second
+ mockEventsFlow.emit(WsEvent.RpcResult(stopId, mapOf("status" to "interrupted")))
+ advanceUntilIdle()
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Later", any(), any()) }
+
+ viewModel.sendQueuedNow(queuedId)
+ advanceUntilIdle()
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Later", any(), true) }
+ }
+
+ @Test
+ fun sendNowWaitsForEarlierAcceptedTurnToReachRestHistory() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ every { HermesWsClient.sendMessage(sessionId, "First", any(), true) } answers {
+ arg<((String) -> Unit)?>(2)?.invoke("first-queued-submit")
+ "first-queued-submit"
+ }
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.sendMessage("First", BusySendMode.QUEUE)
+ viewModel.sendMessage("Second", BusySendMode.QUEUE)
+ advanceUntilIdle()
+
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+ mockEventsFlow.emit(WsEvent.RpcResult("first-queued-submit", mapOf("status" to "queued")))
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ val second =
+ viewModel.uiState.value.pendingSends
+ .single { it.text == "Second" }
+ viewModel.sendQueuedNow(second.id)
+ advanceUntilIdle()
+
+ assertEquals(
+ PendingSendState.QUEUED,
+ viewModel.uiState.value.pendingSends
+ .single { it.id == second.id }
+ .state,
+ )
+ verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
+ }
+
+ @Test
+ fun manualRetryRecommitsAttachmentOnlyAfterPrivateStaging() =
+ runTest {
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val scope =
+ listOf(
+ AuthManager.getBaseUrl(),
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+ val sourceUri = mockk()
+ val snapshotUri = mockk()
+ val resolver = mockk()
+ lateinit var snapshotFile: java.io.File
+ mockkStatic(Uri::class)
+ every { Uri.parse("content://retry/note") } returns sourceUri
+ every { Uri.fromFile(any()) } answers {
+ snapshotFile = firstArg()
+ snapshotUri
+ }
+ every { snapshotUri.toString() } returns "file://private-retry-copy"
+ every { Uri.parse("file://private-retry-copy") } returns snapshotUri
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(7, 8, 9).inputStream() }
+ every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() }
+ mockkConstructor(android.util.Base64OutputStream::class)
+ every { anyConstructed().write(any(), any(), any()) } returns
+ Unit
+ every { anyConstructed().close() } returns Unit
+ every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns
+ CompletableDeferred(mapOf("ref_text" to "@file:note.txt"))
+ store.put(
+ PendingSend(
+ id = "legacy-retry",
+ scope = scope,
+ sessionId = sessionId,
+ text = "Retry attachment",
+ attachments = listOf(Attachment("content://retry/note", "note.txt", "text/plain", 3)),
+ mode = BusySendMode.QUEUE,
+ state = PendingSendState.REJECTED,
+ ),
+ )
+ viewModel.refreshSettings()
+ advanceUntilIdle()
+
+ viewModel.sendQueuedNow("legacy-retry")
+ viewModel.sendQueuedNow("legacy-retry")
+ assertEquals(
+ "content://retry/note",
+ store
+ .all()
+ .single()
+ .attachments
+ .single()
+ .uri,
+ )
+ assertTrue(store.all().single().requiresAttachmentRecovery)
+ advanceUntilIdle()
+
+ assertEquals(
+ "file://private-retry-copy",
+ store
+ .all()
+ .single()
+ .attachments
+ .single()
+ .uri,
+ )
+ assertFalse(store.all().single().requiresAttachmentRecovery)
+ verify(exactly = 1) {
+ HermesWsClient.sendMessage(sessionId, "@file:note.txt\n\nRetry attachment", any(), false)
+ }
+ }
+
+ @Test
+ fun manualRetryPreparationOversizePreservesOriginalReceipt() =
+ runTest {
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val scope =
+ listOf(
+ AuthManager.getBaseUrl(),
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+ val sourceUri = mockk()
+ val snapshotUri = mockk()
+ val resolver = mockk()
+ mockkStatic(Uri::class)
+ every { Uri.parse("content://retry/growing") } returns sourceUri
+ every { Uri.fromFile(any()) } returns snapshotUri
+ every { snapshotUri.toString() } returns "file://retry-growing-copy"
+ every { Uri.parse("file://retry-growing-copy") } returns snapshotUri
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1).inputStream() }
+ every { resolver.openInputStream(snapshotUri) } answers {
+ object : java.io.InputStream() {
+ private var remaining = MAX_CHAT_ATTACHMENT_BYTES + 1
+
+ override fun read(): Int = if (remaining-- > 0) 0 else -1
+
+ override fun read(
+ buffer: ByteArray,
+ offset: Int,
+ length: Int,
+ ): Int {
+ if (remaining <= 0) return -1
+ val count = minOf(length.toLong(), remaining).toInt()
+ remaining -= count
+ return count
+ }
+ }
+ }
+ mockkConstructor(android.util.Base64OutputStream::class)
+ every { anyConstructed().write(any(), any(), any()) } returns
+ Unit
+ every { anyConstructed().close() } returns Unit
+ store.put(
+ PendingSend(
+ id = "retry-growing",
+ scope = scope,
+ sessionId = sessionId,
+ text = "Keep recovery receipt",
+ attachments =
+ listOf(
+ Attachment("content://retry/growing", "growing.bin", "application/octet-stream", 1),
+ ),
+ mode = BusySendMode.QUEUE,
+ state = PendingSendState.REJECTED,
+ ),
+ )
+ val original = store.all().single()
+ viewModel.refreshSettings()
+ advanceUntilIdle()
+ viewModel.sendQueuedNow(original.id)
+ advanceUntilIdle()
+
+ assertEquals(listOf(original), store.all())
+ assertTrue(
+ viewModel.uiState.value.errorMessage
+ ?.contains("too large") == true,
+ )
+ verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) }
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Keep recovery receipt", any(), any()) }
+ }
+
+ @Test
+ fun manualRetryOwnerChangePreservesExistingPrivateSnapshot() =
+ runTest {
+ var baseUrl = "http://test.local/"
+ every { AuthManager.getBaseUrl() } answers { baseUrl }
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val scope =
+ listOf(
+ baseUrl,
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+ val snapshot = java.io.File(attachmentFilesDir, "chat-send/retry-existing/0")
+ snapshot.parentFile.mkdirs()
+ snapshot.writeBytes(byteArrayOf(1, 2, 3))
+ val snapshotUri = mockk()
+ val resolver = mockk()
+ mockkStatic(Uri::class)
+ every { Uri.parse("file://retry-existing") } returns snapshotUri
+ every { Uri.fromFile(any()) } returns snapshotUri
+ every { snapshotUri.toString() } returns "file://retry-existing"
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(snapshotUri) } answers {
+ baseUrl = "http://other-profile.local/"
+ snapshot.inputStream()
+ }
+ mockkConstructor(android.util.Base64OutputStream::class)
+ every { anyConstructed().write(any(), any(), any()) } returns
+ Unit
+ every { anyConstructed().close() } returns Unit
+ val original =
+ PendingSend(
+ id = "retry-existing",
+ scope = scope,
+ sessionId = sessionId,
+ text = "Keep existing snapshot",
+ attachments = listOf(Attachment("file://retry-existing", "note.txt", "text/plain", 3)),
+ mode = BusySendMode.QUEUE,
+ state = PendingSendState.UNKNOWN,
+ )
+ store.put(original)
+ viewModel.sendQueuedNow(original.id)
+ advanceUntilIdle()
+
+ assertEquals(listOf(original), store.all())
+ assertTrue("Rollback must retain the original receipt's private bytes", snapshot.exists())
+ verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) }
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun manualRetryUnreadablePreparationPreservesUnknownReceipt() =
+ runTest {
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val scope =
+ listOf(
+ AuthManager.getBaseUrl(),
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+ val snapshot = java.io.File(attachmentFilesDir, "chat-send/retry-unreadable/0")
+ snapshot.parentFile.mkdirs()
+ snapshot.writeBytes(byteArrayOf(1))
+ val snapshotUri = mockk()
+ val resolver = mockk()
+ mockkStatic(Uri::class)
+ every { Uri.parse("file://retry-unreadable") } returns snapshotUri
+ every { Uri.fromFile(any()) } returns snapshotUri
+ every { snapshotUri.toString() } returns "file://retry-unreadable"
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(snapshotUri) } returns null
+ val original =
+ PendingSend(
+ id = "retry-unreadable",
+ scope = scope,
+ sessionId = sessionId,
+ text = "Keep unknown receipt",
+ attachments = listOf(Attachment("file://retry-unreadable", "note.txt", "text/plain", 1)),
+ mode = BusySendMode.QUEUE,
+ state = PendingSendState.UNKNOWN,
+ )
+ store.put(original)
+ viewModel.sendQueuedNow(original.id)
+ advanceUntilIdle()
+
+ assertEquals(listOf(original), store.all())
+ verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) }
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun legacyContentUriReceiptIsQuarantinedUntilExplicitRecovery() =
+ runTest {
+ val store = ChatSendStore()
+ val scope =
+ listOf(
+ AuthManager.getBaseUrl(),
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+ store.put(
+ PendingSend(
+ id = "legacy-auto-drain",
+ scope = scope,
+ sessionId = "session-123",
+ text = "Needs attachment recovery",
+ attachments = listOf(Attachment("content://legacy/lost", "lost.txt", "text/plain", 4)),
+ mode = BusySendMode.QUEUE,
+ state = PendingSendState.QUEUED,
+ ),
+ )
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+
+ val receipt =
+ viewModel.uiState.value.pendingSends
+ .single()
+ assertEquals(PendingSendState.REJECTED, receipt.state)
+ assertTrue(receipt.requiresAttachmentRecovery)
+ assertEquals("Needs attachment recovery", receipt.text)
+ assertEquals("lost.txt", receipt.attachments.single().name)
+
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Needs attachment recovery", any(), any()) }
+ }
+
+ @Test
+ fun manualAttachmentRetryKeepsOriginalReceiptAcrossSessionChange() =
+ runTest {
+ stubSession456Rests(success = true)
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val scope =
+ listOf(
+ AuthManager.getBaseUrl(),
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+ val sourceUri = mockk()
+ val resolver = mockk()
+ mockkStatic(Uri::class)
+ every { Uri.parse("content://retry/session") } returns sourceUri
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(sourceUri) } answers {
+ viewModel.switchSession("session-456")
+ byteArrayOf(1, 2, 3).inputStream()
+ }
+ store.put(
+ PendingSend(
+ id = "retry-session",
+ scope = scope,
+ sessionId = sessionId,
+ text = "Keep old session receipt",
+ attachments = listOf(Attachment("content://retry/session", "session.txt", "text/plain", 3)),
+ mode = BusySendMode.QUEUE,
+ state = PendingSendState.UNKNOWN,
+ ),
+ )
+ viewModel.refreshSettings()
+
+ viewModel.sendQueuedNow("retry-session")
+ advanceUntilIdle()
+
+ val preserved = store.all().single { it.id == "retry-session" }
+ assertEquals("content://retry/session", preserved.attachments.single().uri)
+ assertEquals(PendingSendState.REJECTED, preserved.state)
+ assertTrue(preserved.requiresAttachmentRecovery)
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Keep old session receipt", any(), any()) }
+ }
+
+ @Test
+ fun manualAttachmentRetryKeepsOriginalReceiptAcrossProfileScopeChange() =
+ runTest {
+ var baseUrl = "http://test.local/"
+ every { AuthManager.getBaseUrl() } answers { baseUrl }
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val scope =
+ listOf(
+ baseUrl,
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+ val sourceUri = mockk()
+ val resolver = mockk()
+ mockkStatic(Uri::class)
+ every { Uri.parse("content://retry/profile") } returns sourceUri
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(sourceUri) } answers {
+ baseUrl = "http://other-profile.local/"
+ byteArrayOf(1, 2, 3).inputStream()
+ }
+ store.put(
+ PendingSend(
+ id = "retry-profile",
+ scope = scope,
+ sessionId = sessionId,
+ text = "Keep old profile receipt",
+ attachments = listOf(Attachment("content://retry/profile", "profile.txt", "text/plain", 3)),
+ mode = BusySendMode.QUEUE,
+ state = PendingSendState.UNKNOWN,
+ ),
+ )
+ viewModel.refreshSettings()
+
+ viewModel.sendQueuedNow("retry-profile")
+ advanceUntilIdle()
+
+ val preserved = store.all().single { it.id == "retry-profile" }
+ assertEquals(scope, preserved.scope)
+ assertEquals("content://retry/profile", preserved.attachments.single().uri)
+ assertEquals(PendingSendState.REJECTED, preserved.state)
+ assertTrue(preserved.requiresAttachmentRecovery)
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Keep old profile receipt", any(), any()) }
+ }
+
+ // ── Send ownership linearization regressions ────────────────────────────
+
+ @Test
+ fun manualAttachmentRetryContextChangeAfterValidationPreservesOriginalReceiptAndDoesNotDispatch() =
+ runTest {
+ var baseUrl = "http://test.local/"
+ var changeContextOnNextStoreEdit = false
+ every { AuthManager.getBaseUrl() } answers { baseUrl }
+ val prefs = mockk()
+ val editor = mockk()
+ every { prefs.getString("rows", null) } returns null
+ every { prefs.edit() } answers {
+ if (changeContextOnNextStoreEdit) {
+ changeContextOnNextStoreEdit = false
+ baseUrl = "http://other-profile.local/"
+ }
+ editor
+ }
+ every { editor.putString("rows", any()) } returns editor
+ every { editor.commit() } returns true
+ val store = ChatSendStore(prefs)
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val scope =
+ listOf(
+ baseUrl,
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+ val sourceUri = mockk()
+ val snapshotUri = mockk()
+ val resolver = mockk()
+ lateinit var snapshotFile: java.io.File
+ mockkStatic(Uri::class)
+ every { Uri.parse("content://retry/post-validation") } returns sourceUri
+ every { Uri.fromFile(any()) } answers {
+ snapshotFile = firstArg()
+ snapshotUri
+ }
+ every { snapshotUri.toString() } returns "file://private-post-validation-copy"
+ every { Uri.parse("file://private-post-validation-copy") } returns snapshotUri
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() }
+ every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() }
+ mockkConstructor(android.util.Base64OutputStream::class)
+ every { anyConstructed().write(any(), any(), any()) } returns
+ Unit
+ every { anyConstructed().close() } returns Unit
+ every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns
+ CompletableDeferred(mapOf("ref_text" to "@file:post-validation.txt"))
+ store.put(
+ PendingSend(
+ id = "retry-post-validation",
+ scope = scope,
+ sessionId = sessionId,
+ text = "Keep post-validation receipt",
+ attachments =
+ listOf(
+ Attachment(
+ "content://retry/post-validation",
+ "post-validation.txt",
+ "text/plain",
+ 3,
+ ),
+ ),
+ mode = BusySendMode.QUEUE,
+ state = PendingSendState.REJECTED,
+ ),
+ )
+ viewModel.refreshSettings()
+ advanceUntilIdle()
+
+ // processPendingSendReservation has already validated the old owner when it
+ // opens the store transaction that would replace the recovery receipt.
+ changeContextOnNextStoreEdit = true
+ viewModel.sendQueuedNow("retry-post-validation")
+ advanceUntilIdle()
+
+ val preserved = store.all().single { it.id == "retry-post-validation" }
+ assertEquals("http://other-profile.local/", baseUrl)
+ assertEquals(scope, preserved.scope)
+ assertEquals("content://retry/post-validation", preserved.attachments.single().uri)
+ assertEquals(PendingSendState.REJECTED, preserved.state)
+ assertTrue(preserved.requiresAttachmentRecovery)
+ verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) }
+ verify(exactly = 0) {
+ HermesWsClient.sendMessage(
+ any(),
+ "@file:post-validation.txt\n\nKeep post-validation receipt",
+ any(),
+ any(),
+ )
+ }
+ }
+
+ @Test
+ fun queuedClaimContextChangeBeforeFinalValidationRollsBackToQueuedWithoutDispatch() =
+ runTest {
+ var baseUrl = "http://test.local/"
+ var armClaimCommit = false
+ var changeContextOnNextScopeRead = false
+ every { AuthManager.getBaseUrl() } answers {
+ if (changeContextOnNextScopeRead) {
+ changeContextOnNextScopeRead = false
+ baseUrl = "http://other-profile.local/"
+ }
+ baseUrl
+ }
+ val prefs = mockk()
+ val editor = mockk()
+ every { prefs.getString("rows", null) } returns null
+ every { prefs.edit() } returns editor
+ every { editor.putString("rows", any()) } returns editor
+ every { editor.commit() } answers {
+ if (armClaimCommit) {
+ armClaimCommit = false
+ changeContextOnNextScopeRead = true
+ }
+ true
+ }
+ val store = ChatSendStore(prefs)
+ val scope =
+ listOf(
+ baseUrl,
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+ store.put(
+ PendingSend(
+ id = "queue-post-claim-context-change",
+ scope = scope,
+ sessionId = "session-123",
+ text = "Claimed then stale",
+ mode = BusySendMode.QUEUE,
+ state = PendingSendState.QUEUED,
+ ),
+ )
+
+ // The claim's durable commit arms the first scope read in publishPendingSends,
+ // after update() has installed SENDING but before drainPendingQueue's final check.
+ armClaimCommit = true
+ val (_, sessionId) = createViewModelWithSession(sendStore = store)
+ mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId))
+ advanceUntilIdle()
+
+ val recoverable = store.all().single { it.id == "queue-post-claim-context-change" }
+ assertEquals("http://other-profile.local/", baseUrl)
+ assertEquals(PendingSendState.QUEUED, recoverable.state)
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Claimed then stale", any(), any()) }
+ }
+
+ @Test
+ fun manualAttachmentRetryContextChangeBeforeAttachmentRpcPreservesOriginalReceipt() =
+ runTest {
+ var baseUrl = "http://test.local/"
+ every { AuthManager.getBaseUrl() } answers { baseUrl }
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val scope =
+ listOf(
+ baseUrl,
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+ val sourceUri = mockk()
+ val snapshotUri = mockk()
+ val resolver = mockk()
+ lateinit var snapshotFile: java.io.File
+ mockkStatic(Uri::class)
+ every { Uri.parse("content://retry/pre-attachment-rpc") } returns sourceUri
+ every { Uri.fromFile(any()) } answers {
+ snapshotFile = firstArg()
+ snapshotUri
+ }
+ every { snapshotUri.toString() } returns "file://private-pre-attachment-rpc"
+ every { Uri.parse("file://private-pre-attachment-rpc") } returns snapshotUri
+ every { app.contentResolver } returns resolver
+ every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() }
+ every { resolver.openInputStream(snapshotUri) } answers {
+ baseUrl = "http://other-profile.local/"
+ snapshotFile.inputStream()
+ }
+ mockkConstructor(android.util.Base64OutputStream::class)
+ every { anyConstructed().write(any(), any(), any()) } returns
+ Unit
+ every { anyConstructed().close() } returns Unit
+ every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns
+ CompletableDeferred(mapOf("ref_text" to "@file:pre-attachment-rpc.txt"))
+ store.put(
+ PendingSend(
+ id = "retry-pre-attachment-rpc",
+ scope = scope,
+ sessionId = sessionId,
+ text = "Keep pre-attachment receipt",
+ attachments =
+ listOf(
+ Attachment(
+ "content://retry/pre-attachment-rpc",
+ "pre-attachment-rpc.txt",
+ "text/plain",
+ 3,
+ ),
+ ),
+ mode = BusySendMode.QUEUE,
+ state = PendingSendState.REJECTED,
+ ),
+ )
+ viewModel.refreshSettings()
+ advanceUntilIdle()
+
+ viewModel.sendQueuedNow("retry-pre-attachment-rpc")
+ advanceUntilIdle()
+
+ val preserved = store.all().single { it.id == "retry-pre-attachment-rpc" }
+ assertEquals(scope, preserved.scope)
+ assertEquals("content://retry/pre-attachment-rpc", preserved.attachments.single().uri)
+ assertEquals(PendingSendState.REJECTED, preserved.state)
+ assertTrue(preserved.requiresAttachmentRecovery)
+ verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) }
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun acceptedReceiptCanBeRetriedOnlyByExplicitSendAgain() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ every { HermesWsClient.sendMessage(sessionId, "Possibly buffered", any(), false) } answers {
+ arg<((String) -> Unit)?>(2)?.invoke("buffered-submit")
+ "buffered-submit"
+ }
+ viewModel.sendMessage("Possibly buffered")
+ advanceUntilIdle()
+ mockEventsFlow.emit(WsEvent.RpcResult("buffered-submit", mapOf("status" to "streaming")))
+ advanceUntilIdle()
+ val receipt =
+ viewModel.uiState.value.pendingSends
+ .single()
+ assertEquals(PendingSendState.ACCEPTED, receipt.state)
+ verify(exactly = 0) { HermesWsClient.sendMessage(sessionId, "Possibly buffered", any(), true) }
+
+ viewModel.sendQueuedNow(receipt.id)
+ advanceUntilIdle()
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Possibly buffered", any(), true) }
+ }
+
+ @Test
+ fun disconnectQuarantinesAcceptedReceiptWithoutAutomaticResend() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ every { HermesWsClient.sendMessage(sessionId, "Accepted before disconnect", any(), false) } answers {
+ arg<((String) -> Unit)?>(2)?.invoke("accepted-before-disconnect")
+ "accepted-before-disconnect"
+ }
+ viewModel.sendMessage("Accepted before disconnect")
+ advanceUntilIdle()
+ mockEventsFlow.emit(
+ WsEvent.RpcResult("accepted-before-disconnect", mapOf("status" to "streaming")),
+ )
+ advanceUntilIdle()
+ assertEquals(
+ PendingSendState.ACCEPTED,
+ viewModel.uiState.value.pendingSends
+ .single()
+ .state,
+ )
+
+ mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
+ advanceUntilIdle()
+
+ assertEquals(
+ PendingSendState.UNKNOWN,
+ viewModel.uiState.value.pendingSends
+ .single()
+ .state,
+ )
+ verify(exactly = 1) {
+ HermesWsClient.sendMessage(sessionId, "Accepted before disconnect", any(), false)
+ }
+ verify(exactly = 0) {
+ HermesWsClient.sendMessage(sessionId, "Accepted before disconnect", any(), true)
+ }
+ }
+
+ @Test
+ fun sendNowPromotesTheSelectedQueuedMessageWhileIdle() =
+ runTest {
+ val store = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = store)
+ val scope =
+ listOf(
+ AuthManager.getBaseUrl(),
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+ store.put(PendingSend("first", scope, sessionId, "First queued", mode = BusySendMode.QUEUE))
+ store.put(PendingSend("second", scope, sessionId, "Second queued", mode = BusySendMode.QUEUE))
+ viewModel.refreshSettings()
+ advanceUntilIdle()
+
+ viewModel.sendQueuedNow("second")
+ advanceUntilIdle()
+
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Second queued", any(), true) }
+ verify(exactly = 0) { HermesWsClient.sendMessage(sessionId, "First queued", any(), any()) }
+ assertEquals(PendingSendState.QUEUED, store.all().single { it.id == "first" }.state)
+ }
+
+ @Test
+ fun pendingQueueIsScopedByConnectionProfileAndStoredSession() =
+ runTest {
+ var connectionProfile = "first"
+ every { AuthManager.getSelectedProfileId() } answers { connectionProfile }
+ stubSession456Rests(success = true)
+ val sendStore = ChatSendStore()
+ val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore)
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ advanceUntilIdle()
+ viewModel.sendMessage("Only here", BusySendMode.QUEUE)
+ advanceUntilIdle()
+ assertEquals(1, viewModel.uiState.value.pendingSends.size)
+
+ connectionProfile = "second"
+ viewModel.refreshSettings()
+ advanceUntilIdle()
+ assertTrue(
+ viewModel.uiState.value.pendingSends
+ .isEmpty(),
+ )
+ connectionProfile = "first"
+ viewModel.refreshSettings()
+ advanceUntilIdle()
+ assertEquals(1, viewModel.uiState.value.pendingSends.size)
+
+ viewModel.switchSession("session-456")
+ advanceUntilIdle()
+ assertTrue(
+ viewModel.uiState.value.pendingSends
+ .isEmpty(),
+ )
+ }
+
+ @Test
+ fun restoredQueueDrainsAfterResumeHydrationWhenAckArrivesFirst() =
+ runTest {
+ stubSession456Rests(success = true)
+ val response =
+ CompletableDeferred>()
+ coEvery {
+ ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any())
+ } coAnswers { response.await() }
+ val store = ChatSendStore()
+ val (viewModel, _) = createViewModelWithSession(sendStore = store)
+ val captured = captureSends()
+ val scope =
+ listOf(
+ AuthManager.getBaseUrl(),
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+ store.put(PendingSend("restored", scope, "session-456", "After relaunch", mode = BusySendMode.QUEUE))
+
+ viewModel.switchSession("session-456")
+ runCurrent()
+ val resumeId = captured.last { it.first == WsMethods.SESSION_RESUME }.second
+ mockEventsFlow.emit(WsEvent.RpcResult(resumeId, mapOf("session_id" to "runtime-456", "running" to false)))
+ runCurrent()
+ assertFalse(viewModel.uiState.value.isSessionReady)
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), "After relaunch", any(), any()) }
+
+ response.complete(retrofit2.Response.success(SessionMessagesResponse(messages = emptyList())))
+ advanceUntilIdle()
+ assertTrue(viewModel.uiState.value.isSessionReady)
+ verify(exactly = 1) { HermesWsClient.sendMessage("runtime-456", "After relaunch", any(), true) }
+ }
+
+ @Test
+ fun sessionSwitchCancelsObsoleteDrainAndDispatchesRestoredQueue() =
+ runTest {
+ stubSession456Rests(success = true)
+ val store = ChatSendStore()
+ val (viewModel, _) = createViewModelWithSession(sendStore = store)
+ val scope =
+ listOf(
+ AuthManager.getBaseUrl(),
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+ // Model an active session-A staging job without real threads or blocking I/O.
+ // Reset must release ownership before session B becomes ready to drain.
+ val obsoleteDrain = Job()
+ ChatViewModel::class.java
+ .getDeclaredField("queueDrainJob")
+ .apply { isAccessible = true }
+ .set(viewModel, obsoleteDrain)
+ every { HermesWsClient.sendMessage("runtime-456", "Session B queued", any(), true) } answers {
+ arg<((String) -> Unit)?>(2)?.invoke("session-b-submit")
+ "session-b-submit"
+ }
+ store.put(
+ PendingSend(
+ "session-b-restored",
+ scope,
+ "session-456",
+ "Session B queued",
+ mode = BusySendMode.QUEUE,
+ ),
+ )
+ viewModel.switchSession("session-456")
+ runCurrent()
+ val resumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second
+ mockEventsFlow.emit(
+ WsEvent.RpcResult(resumeId, mapOf("session_id" to "runtime-456", "running" to false)),
+ )
+ advanceUntilIdle()
+ assertTrue(viewModel.uiState.value.isSessionReady)
+ assertTrue("Session switch must cancel the obsolete drain", obsoleteDrain.isCancelled)
+ verify(exactly = 1) {
+ HermesWsClient.sendMessage("runtime-456", "Session B queued", any(), true)
+ }
+ verify(exactly = 0) {
+ HermesWsClient.sendMessage(any(), "Session A queued", any(), any())
+ }
+ }
+
+ @Test
+ fun timeoutKeepsRecoverableReceiptAndLateAckDoesNotResend() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ val requestId = "timeout-request"
+ every { HermesWsClient.sendMessage(sessionId, "Maybe", any(), any()) } answers {
+ arg<((String) -> Unit)?>(2)?.invoke(requestId)
+ requestId
+ }
+ viewModel.sendMessage("Maybe")
+ advanceUntilIdle()
+ viewModel.expireOutgoingRequest(requestId)
+ advanceUntilIdle()
+ assertEquals(
+ PendingSendState.UNKNOWN,
+ viewModel.uiState.value.pendingSends
+ .single()
+ .state,
+ )
+ assertFalse(
+ viewModel.uiState.value.messages
+ .any { it.content == "Maybe" },
+ )
+
+ mockEventsFlow.emit(WsEvent.RpcResult(requestId, mapOf("status" to "streaming")))
+ advanceUntilIdle()
+ assertEquals(
+ PendingSendState.ACCEPTED,
+ viewModel.uiState.value.pendingSends
+ .single()
+ .state,
+ )
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Maybe", any(), any()) }
+ }
+
+ @Test
+ fun restCompletionBeforePromptAckSettlesReceiptAndKeepsCompletionIdentity() =
+ runTest {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ val promptId = "late-prompt-ack"
+ every { HermesWsClient.sendMessage(sessionId, "Fast", any(), any()) } answers {
+ arg<((String) -> Unit)?>(2)?.invoke(promptId)
+ promptId
+ }
+ coEvery {
+ ApiClient.hermesApi.getSessionMessages(sessionId, any(), any(), any(), any())
+ } returns
+ retrofit2.Response.success(
+ SessionMessagesResponse(
+ messages =
+ listOf(
+ SessionMessage(
+ id = 4,
+ role = "user",
+ content = JsonPrimitive("Fast"),
+ timestamp = JsonPrimitive(System.currentTimeMillis()),
+ ),
+ ),
+ pagination = PaginationInfo(order = "latest"),
+ ),
+ )
+
+ viewModel.sendMessage("Fast")
+ advanceUntilIdle()
+ mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
+ mockEventsFlow.emit(WsEvent.MessageComplete("Answer", sessionId, completionId = "completion-fast"))
+ advanceUntilIdle()
+ assertTrue(
+ viewModel.uiState.value.pendingSends
+ .isEmpty(),
+ )
+ assertTrue(
+ viewModel.uiState.value.messages
+ .any { it.completionId == "completion-fast" },
+ )
+
+ mockEventsFlow.emit(WsEvent.RpcResult(promptId, mapOf("status" to "streaming")))
+ advanceUntilIdle()
+ assertTrue(
+ viewModel.uiState.value.pendingSends
+ .isEmpty(),
+ )
+ verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Fast", any(), any()) }
+ }
+
// ── Session switch ───────────────────────────────────────────────────────
@Test
@@ -4982,17 +6864,24 @@ class ChatViewModelTest {
viewModel.sendMessage("Here is an image")
advanceUntilIdle()
- // Error path is logged so we can diagnose the failed read.
- verify { Log.e(any(), match { it.contains("Permission denied") }, any()) }
-
- // Graceful handling: the message is still sent even though the
- // attachment bytes couldn't be read (no crash, no lost message).
- val sent =
+ // Private staging failed before a durable receipt or outbound RPC existed.
+ assertFalse(
viewModel.uiState.value.messages
- .firstOrNull { it.id == sessionId } != null ||
- viewModel.uiState.value.messages
- .any { it.content == "Here is an image" }
- assertTrue("Message should still be sent despite attachment read failure", sent)
+ .any { it.content == "Here is an image" },
+ )
+ assertTrue(
+ viewModel.uiState.value.pendingSends
+ .isEmpty(),
+ )
+ assertEquals("Here is an image", viewModel.uiState.value.composerTextToRestore)
+ assertEquals(
+ "content://dummy",
+ viewModel.uiState.value.pendingAttachments
+ .single()
+ .uri,
+ )
+ verify(exactly = 0) { Log.e(any(), match { it.contains("Permission denied") }, any()) }
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
}
/**
@@ -5031,11 +6920,20 @@ class ChatViewModelTest {
// The image bytes read via ContentResolver must succeed.
mockkStatic(Uri::class)
val mockUri = mockk()
+ val snapshotUri = mockk()
+ lateinit var snapshotFile: java.io.File
every { Uri.parse("content://dummy") } returns mockUri
+ every { Uri.fromFile(any()) } answers {
+ snapshotFile = firstArg()
+ snapshotUri
+ }
+ every { snapshotUri.toString() } returns "file://private-image-copy"
+ every { Uri.parse("file://private-image-copy") } returns snapshotUri
val contentResolver = mockk()
every { app.contentResolver } returns contentResolver
- every { contentResolver.openInputStream(any()) } returns
+ every { contentResolver.openInputStream(mockUri) } returns
java.io.ByteArrayInputStream(byteArrayOf(1, 2, 3, 4))
+ every { contentResolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() }
viewModel.addAttachment("content://dummy", "test.png", "image/png", 1000)
advanceUntilIdle()
@@ -6925,7 +8823,7 @@ class ChatViewModelTest {
vm.uiState.value.messages
.any { it.content == "My super important long prompt" },
)
- assertTrue(vm.uiState.value.isAgentTyping)
+ assertTrue(vm.uiState.value.isSending)
// But wsClient.sendMessage not dispatched yet because session_id was null
assertTrue(sentPrompts.isEmpty())
@@ -7000,7 +8898,7 @@ class ChatViewModelTest {
vm.sendMessage("Prompt before failure")
advanceUntilIdle()
- assertTrue(vm.uiState.value.isAgentTyping)
+ assertTrue(vm.uiState.value.isSending)
val createReqId = "req-id-$reqCount"
// Emit RPC error for SESSION_CREATE
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt
index 3b0c064b..2b28ef68 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt
@@ -94,6 +94,10 @@ class SlashCommandDispatchRpcTest {
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token"
+ every { AuthManager.getPinnedModels() } returns emptyList()
+ every { AuthManager.getBaseUrl() } returns "http://test.local/"
+ every { AuthManager.getSelectedProfileId() } returns null
+ every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false
@@ -152,7 +156,15 @@ class SlashCommandDispatchRpcTest {
}
private suspend fun TestScope.createViewModelWithSession(): Pair {
- val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
+ val vm =
+ ChatViewModel(
+ app,
+ false,
+ fakeRepo,
+ FakeSlashUsageStore(),
+ ioDispatcher = testDispatcher,
+ sendStore = ChatSendStore(),
+ )
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt
index c886e921..a7a3a8e3 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt
@@ -83,6 +83,10 @@ class FakeChatMessageDao : ChatMessageDao {
messages.values.removeAll { it.sessionId == sessionId }
}
+ override suspend fun deleteUnconfirmedMessage(id: String) {
+ if (messages[id]?.restId == null) messages.remove(id)
+ }
+
/** Direct access for test setup — bypasses the suspend modifier. */
fun addMessageDirect(message: ChatMessageEntity) {
val existing = messages[message.id]
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt
index 9df61ed2..2d678c93 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.settings
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
+import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.theme.ThemePreference
@@ -47,6 +48,17 @@ class SettingsViewModelTest {
return vm
}
+ @Test
+ fun busySendDefaultLoadsAndSaves() {
+ every { AuthManager.getBusySendMode() } returns BusySendMode.GUIDE
+ val viewModel = createViewModel()
+ assertEquals(BusySendMode.GUIDE, viewModel.uiState.value.busySendMode)
+
+ viewModel.onBusySendModeChange(BusySendMode.QUEUE)
+ assertEquals(BusySendMode.QUEUE, viewModel.uiState.value.busySendMode)
+ verify(exactly = 1) { AuthManager.setBusySendMode(BusySendMode.QUEUE) }
+ }
+
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
@@ -71,6 +83,8 @@ class SettingsViewModelTest {
every { AuthManager.isUseDynamicColors() } returns true
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
every { AuthManager.isTypingEffectEnabled() } returns true
+ every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
+ every { AuthManager.setBusySendMode(any()) } returns Unit
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatFontScale() } returns 1.0f
every { AuthManager.isMessageStatsEnabled() } returns false
From 4d1720ad4d22966e281966a1f3be15bf3f5fa0d4 Mon Sep 17 00:00:00 2001
From: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
Date: Fri, 25 Sep 2026 13:17:38 +0200
Subject: [PATCH 006/107] fix(chat): keep restored legacy prompts out of the
live tail (#1260)
Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
---
.../m57/hermescontrol/ui/chat/ChatMessage.kt | 6 +
.../ui/chat/ChatTranscriptMerge.kt | 12 +-
.../hermescontrol/ui/chat/ChatViewModel.kt | 47 +++-
.../ui/chat/ChatCachedHistoryTest.kt | 32 +++
.../ui/chat/ChatViewModelTest.kt | 211 +++++++++++++++++-
5 files changed, 295 insertions(+), 13 deletions(-)
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt
index 34d6f29b..4a33fb46 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt
@@ -101,6 +101,12 @@ data class ChatMessage(
val messageProvenance: MessageProvenance = MessageProvenance.UNKNOWN,
/** Read from history, not observed live in this view. Never persisted as delivery state. */
val isHistoricalCache: Boolean = false,
+ /**
+ * Legacy USER restored without a canonical identity or a current send receipt.
+ * Placement only: keep it outside the live tail without asserting delivery.
+ * Transient; UNKNOWN provenance and the persisted row remain unchanged.
+ */
+ val isRestoredUnconfirmed: Boolean = false,
)
/** Cached REST rows already carry their canonical identity in the persisted primary key. */
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt
index fcb7dc9b..a04c7951 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt
@@ -335,6 +335,7 @@ internal fun dedupeCachedMessages(
restId = it.canonicalRestId,
completionId = message.completionId ?: it.completionId,
displayKind = message.displayKind ?: it.displayKind,
+ isRestoredUnconfirmed = false,
)
} ?: message
}
@@ -403,6 +404,9 @@ internal fun mergeCachedTranscriptPage(
restId = match.canonicalRestId ?: message.canonicalRestId,
completionId = match.completionId ?: message.completionId,
displayKind = match.displayKind ?: message.displayKind,
+ isRestoredUnconfirmed =
+ match.isRestoredUnconfirmed && message.isRestoredUnconfirmed &&
+ match.canonicalRestId == null && message.canonicalRestId == null,
)
}.toMap()
val resolvedOrders =
@@ -437,6 +441,7 @@ internal fun mergeTranscriptWithLive(
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
displayKind = message.displayKind ?: match.displayKind,
isHistoricalCache = false,
+ isRestoredUnconfirmed = false,
)
}
@@ -464,6 +469,7 @@ internal fun mergeTranscriptWithLive(
tokenCount = message.tokenCount ?: match.tokenCount,
completionId = message.completionId ?: match.completionId,
isHistoricalCache = false,
+ isRestoredUnconfirmed = false,
)
}
@@ -482,6 +488,7 @@ internal fun mergeTranscriptWithLive(
content = mergedContent,
completionId = message.completionId ?: match?.completionId,
isHistoricalCache = false,
+ isRestoredUnconfirmed = false,
)
}
}
@@ -533,9 +540,10 @@ private fun List.inTranscriptOrder(
?: latestCanonical
}
if (hasPendingPredecessor) pendingOrderByLocal[message.id] = pendingLocalOrder ?: Long.MAX_VALUE
- } else if (message.isHistoricalCache) {
+ } else if (message.isHistoricalCache || message.isRestoredUnconfirmed) {
// Room groups UUID-only rows after all confirmed rows; that predecessor is
- // not a chronological anchor. Keep unresolved history before the server window.
+ // not a chronological anchor. Restored legacy rows stay before the server
+ // window too, without promoting their uncertain delivery to confirmed history.
localAnchors[message.id] = beforeCanonical
} else {
hasPendingPredecessor = true
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
index 05f5101a..5d6ddc93 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
@@ -3334,6 +3334,14 @@ class ChatViewModel(
role = MessageRole.USER,
content = displayContent,
tokenCount = TokenEstimator.estimate(displayContent).takeIf { it > 0 },
+ // Follow-up to #1253: stripped /queue text is an unconfirmed prompt,
+ // not a permanently-local command or an ambiguous legacy cache row.
+ messageProvenance =
+ if (result is SlashResult.QueuePrompt && displayContent != command) {
+ MessageProvenance.LOCAL_PENDING
+ } else {
+ MessageProvenance.UNKNOWN
+ },
)
val sessionId = _uiState.value.currentSessionId
@@ -4417,15 +4425,24 @@ class ChatViewModel(
// Receipt bubbles can be absent after rejection/reconnect. Offer their identities
// to the canonical matcher without displaying unconfirmed messages again.
val visibleIds = snapshot.messages.map { it.id }.toSet()
+ val currentSendScope = sendScope()
+ val receiptRows =
+ sendStore
+ .all()
+ .filter { it.scope == currentSendScope && it.sessionId == snapshot.currentSessionId }
+ val receiptBackedIds =
+ receiptRows
+ .filter {
+ it.state in
+ setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
+ }.mapTo(mutableSetOf()) { it.id }
val receiptCandidates =
if (cached) {
emptyList()
} else {
- sendStore
- .all()
+ receiptRows
.filter {
- it.scope == sendScope() && it.sessionId == snapshot.currentSessionId &&
- it.id !in visibleIds &&
+ it.id !in visibleIds &&
it.state in
setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
}.map {
@@ -4444,8 +4461,8 @@ class ChatViewModel(
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.
+ // Legacy UUID-only USER rows remain unconfirmed, but restored placement is
+ // independent of delivery: UNKNOWN must not pin old prompts after fresh replies.
val page =
if (cached) {
mapped.map { message ->
@@ -4459,6 +4476,24 @@ class ChatViewModel(
message.role == MessageRole.USER -> false
else -> true
},
+ isRestoredUnconfirmed =
+ when {
+ message.role != MessageRole.USER ||
+ message.canonicalRestId != null ||
+ message.isPermanentlyLocal() ||
+ message.messageProvenance == MessageProvenance.LOCAL_PENDING ||
+ message.id in receiptBackedIds -> {
+ false
+ }
+
+ currentById[message.id] != null -> {
+ currentById.getValue(message.id).isRestoredUnconfirmed
+ }
+
+ else -> {
+ true
+ }
+ },
)
}
} else {
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCachedHistoryTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCachedHistoryTest.kt
index 6d7ba108..1e446aec 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCachedHistoryTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCachedHistoryTest.kt
@@ -130,6 +130,38 @@ class ChatCachedHistoryTest {
assertEquals(listOf(start.id, historicalUser.id, canonical.id), merged.map { it.id })
}
+ @Test
+ fun restoredRepeatedUsersKeepBothOccurrencesUntilEachCanonicalPageConfirmsOne() {
+ val first =
+ ChatMessage(
+ id = "restored-first",
+ role = MessageRole.USER,
+ content = "continue",
+ localOrder = 1,
+ isRestoredUnconfirmed = true,
+ )
+ val second = first.copy(id = "restored-second", localOrder = 2)
+ val latest = ChatMessage(id = "rest-s-100", role = MessageRole.ASSISTANT, content = "Latest answer")
+ val restored = mergeCachedTranscriptPage(listOf(first, second), listOf(latest))
+ assertEquals(listOf(first.id, second.id, latest.id), restored.map { it.id })
+ assertTrue(restored.take(2).all { it.canonicalRestId == null })
+
+ val older = ChatMessage(id = "rest-s-20", role = MessageRole.USER, content = "continue")
+ val once = mergeTranscriptWithLive(listOf(older), restored, chronological = false)
+ assertEquals(2, once.count { it.role == MessageRole.USER })
+ assertEquals(1, once.count { it.canonicalRestId == older.id })
+ val confirmed = once.single { it.canonicalRestId == older.id }
+ assertFalse(confirmed.isRestoredUnconfirmed)
+ val remaining = once.single { it.role == MessageRole.USER && it.canonicalRestId == null }
+ assertTrue(remaining.isRestoredUnconfirmed)
+ assertEquals(MessageProvenance.UNKNOWN, remaining.messageProvenance)
+
+ val twice = mergeTranscriptWithLive(listOf(older.copy(id = "rest-s-21")), once, chronological = false)
+ assertEquals(listOf(first.id, second.id, latest.id), twice.map { it.id })
+ assertEquals(listOf("rest-s-20", "rest-s-21"), twice.take(2).map { it.canonicalRestId })
+ assertTrue(twice.none { it.isRestoredUnconfirmed })
+ }
+
@Test
fun historicalRowsPrecedeCanonicalWindowWithoutChangingLiveTailOrAttachments() {
val attachment = Attachment("content://test/file", "file.txt", "text/plain")
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
index 57f46ef5..f750132f 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
@@ -7564,6 +7564,204 @@ class ChatViewModelTest {
)
}
+ @Test
+ fun paging_restoredLegacyUserBlockBeforeRestDoesNotFollowNewestReply() =
+ runTest { verifyRestoredLegacyUserPlacement(cacheFirst = true) }
+
+ @Test
+ fun paging_restoredLegacyUserBlockAfterRestDoesNotFollowNewestReply() =
+ runTest { verifyRestoredLegacyUserPlacement(cacheFirst = false) }
+
+ private suspend fun TestScope.verifyRestoredLegacyUserPlacement(cacheFirst: Boolean) {
+ val restored =
+ (1..3).map { index ->
+ ChatMessage(
+ id = "restored-legacy-$index",
+ role = MessageRole.USER,
+ content = "Earlier prompt $index",
+ // Placement must not infer delivery or chronology from wall-clock age.
+ timestamp = Long.MAX_VALUE - index,
+ )
+ }
+ val pending =
+ ChatMessage(
+ id = "real-pending",
+ role = MessageRole.USER,
+ content = "Unsent current prompt",
+ timestamp = 1L,
+ messageProvenance = MessageProvenance.LOCAL_PENDING,
+ )
+ (restored + pending).forEach { fakeRepo.persistMessage(it, "session-456") }
+ val cacheRead = CompletableDeferred()
+ if (!cacheFirst) fakeRepo.dao.beforeRead = { cacheRead.await() }
+ val response = CompletableDeferred>()
+ val (viewModel, _) = createViewModelWithSession()
+ val api = ApiClient.hermesApi
+ coEvery {
+ api.getSessionMessages("session-456", any(), any(), any(), any())
+ } coAnswers { response.await() }
+
+ viewModel.switchSession("session-456")
+ runCurrent()
+ response.complete(pagingResponse(100..102))
+ runCurrent()
+ cacheRead.complete(Unit)
+ advanceUntilIdle()
+
+ val expected = restored.map { it.id } + (100..102).map { "rest-session-456-$it" } + pending.id
+ assertEquals(
+ "Restored legacy prompts must not form a stale block after the newest reply",
+ expected,
+ viewModel.uiState.value.messages
+ .map { it.id },
+ )
+ restored.forEach { original ->
+ val retained =
+ viewModel.uiState.value.messages
+ .single { it.id == original.id }
+ assertEquals(original.content, retained.content)
+ assertEquals(original.timestamp, retained.timestamp)
+ assertEquals(MessageProvenance.UNKNOWN, retained.messageProvenance)
+ assertNull("Moving a row must not invent delivery confirmation", retained.canonicalRestId)
+ assertFalse("Restored placement is not proven historical delivery", retained.isHistoricalCache)
+ }
+ assertTrue(fakeRepo.dao.idsForSession("session-456").containsAll(restored.map { it.id } + pending.id))
+ assertEquals(0, fakeRepo.dao.fullSessionReads)
+ coVerify(exactly = 1) {
+ api.getSessionMessages("session-456", any(), any(), any(), any())
+ }
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
+
+ viewModel.syncCurrentSession()
+ advanceUntilIdle()
+ assertEquals(
+ expected,
+ viewModel.uiState.value.messages
+ .map { it.id },
+ )
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun paging_queuePromptsPersistPendingProvenanceAfterRestart() = runTest { verifyQueuedPromptRestoration("/queue") }
+
+ @Test
+ fun paging_queueAliasPromptsPersistPendingProvenanceAfterRestart() = runTest { verifyQueuedPromptRestoration("/q") }
+
+ private suspend fun TestScope.verifyQueuedPromptRestoration(command: String) {
+ val (viewModel, sessionId) = createViewModelWithSession()
+ assertTrue(viewModel.sendMessage("$command pending queued prompt"))
+ advanceUntilIdle()
+ val queued =
+ viewModel.uiState.value.messages
+ .single { it.role == MessageRole.USER }
+ assertEquals("pending queued prompt", queued.content)
+ assertEquals(
+ "Queued prompts must retain durable pending provenance",
+ MessageProvenance.LOCAL_PENDING.name,
+ fakeRepo.dao.getMessage(queued.id)?.messageProvenance,
+ )
+
+ val api = ApiClient.hermesApi
+ coEvery {
+ api.getSessionMessages(sessionId, any(), any(), any(), any())
+ } returns pagingResponse(100..102)
+ val recreated = createViewModel()
+ advanceUntilIdle()
+ recreated.switchSession(sessionId)
+ advanceUntilIdle()
+
+ val restored = recreated.uiState.value.messages
+ assertEquals(queued.id, restored.last().id)
+ assertTrue(restored.indexOfFirst { it.id == "rest-$sessionId-102" } in 0 until restored.lastIndex)
+ assertEquals(MessageProvenance.LOCAL_PENDING, restored.last().messageProvenance)
+ assertNull(restored.last().canonicalRestId)
+ assertFalse(restored.last().isHistoricalCache)
+ verify(exactly = 1) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun paging_coldRestartReceiptBackedUnknownUserRemainsLiveTail() =
+ runTest {
+ val receiptBacked =
+ ChatMessage(
+ id = "receipt-backed-unknown-user",
+ role = MessageRole.USER,
+ content = "possibly accepted before restart",
+ messageProvenance = MessageProvenance.UNKNOWN,
+ )
+ val rejected =
+ ChatMessage(
+ id = "receipt-backed-rejected-user",
+ role = MessageRole.USER,
+ content = "rejected before restart",
+ messageProvenance = MessageProvenance.UNKNOWN,
+ )
+ fakeRepo.persistMessage(receiptBacked, "session-456")
+ fakeRepo.persistMessage(rejected, "session-456")
+ val store = ChatSendStore()
+ val scope =
+ listOf(
+ AuthManager.getBaseUrl(),
+ AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID,
+ AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID,
+ ).joinToString("\u001f")
+ store.put(
+ PendingSend(
+ id = receiptBacked.id,
+ scope = scope,
+ sessionId = "session-456",
+ text = receiptBacked.content,
+ mode = BusySendMode.QUEUE,
+ state = PendingSendState.UNKNOWN,
+ ),
+ )
+ store.put(
+ PendingSend(
+ id = rejected.id,
+ scope = scope,
+ sessionId = "session-456",
+ text = rejected.content,
+ mode = BusySendMode.QUEUE,
+ state = PendingSendState.REJECTED,
+ ),
+ )
+ val cacheRead = CompletableDeferred()
+ fakeRepo.dao.beforeRead = { cacheRead.await() }
+ val (viewModel, _) = createViewModelWithSession(sendStore = store)
+ coEvery {
+ ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any())
+ } returns pagingResponse(100..102)
+
+ viewModel.switchSession("session-456")
+ runCurrent()
+ cacheRead.complete(Unit)
+ advanceUntilIdle()
+
+ assertEquals(
+ listOf(rejected.id) + (100..102).map { "rest-session-456-$it" } + receiptBacked.id,
+ viewModel.uiState.value.messages
+ .map { it.id },
+ )
+ val rejectedRetained =
+ viewModel.uiState.value.messages
+ .single { it.id == rejected.id }
+ assertTrue(rejectedRetained.isRestoredUnconfirmed)
+ assertEquals(MessageProvenance.UNKNOWN, rejectedRetained.messageProvenance)
+ val retained =
+ viewModel.uiState.value.messages
+ .single { it.id == receiptBacked.id }
+ assertFalse(retained.isHistoricalCache)
+ assertFalse(retained.isRestoredUnconfirmed)
+ assertEquals(MessageProvenance.UNKNOWN, retained.messageProvenance)
+ assertNull(retained.canonicalRestId)
+ assertEquals(PendingSendState.UNKNOWN, store.all().single { it.id == receiptBacked.id }.state)
+ assertEquals(PendingSendState.REJECTED, store.all().single { it.id == rejected.id }.state)
+ verify(exactly = 0) {
+ HermesWsClient.sendMessage(any(), receiptBacked.content, any(), any())
+ }
+ }
+
@Test
fun paging_coldRestartLegacyUnknownUserRemainsConservativelyUnconfirmed() =
runTest {
@@ -7585,15 +7783,18 @@ class ChatViewModelTest {
cacheRead.complete(Unit)
advanceUntilIdle()
assertEquals(
- (100..102).map { "rest-session-456-$it" } + ambiguous.id,
+ listOf(ambiguous.id) + (100..102).map { "rest-session-456-$it" },
viewModel.uiState.value.messages
.map { it.id },
)
- assertFalse(
+ val retained =
viewModel.uiState.value.messages
- .last()
- .isHistoricalCache,
- )
+ .single { it.id == ambiguous.id }
+ assertFalse(retained.isHistoricalCache)
+ assertTrue(retained.isRestoredUnconfirmed)
+ assertEquals(MessageProvenance.UNKNOWN, retained.messageProvenance)
+ assertNull(retained.canonicalRestId)
+ assertTrue(fakeRepo.dao.idsForSession("session-456").contains(ambiguous.id))
}
@Test
From d59965f778b2e0ae33974b277f68d5033f71957e Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 25 Sep 2026 14:18:28 +0300
Subject: [PATCH 007/107] chore: bump latex from 1.5.5 to 1.5.6 (#1259)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a6841a43..892f140f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -30,7 +30,7 @@ coil = "3.6.3"
room = "3.0.2"
sqlcipher = "4.19.0"
datastore = "1.1.2"
-latex = "1.5.5"
+latex = "1.5.6"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
From 94b4a5eaf08a5d6432e5f308385eb3769bd38f24 Mon Sep 17 00:00:00 2001
From: m57 <41645758+Hy4ri@users.noreply.github.com>
Date: Fri, 25 Sep 2026 14:41:07 +0300
Subject: [PATCH 008/107] fix(codeql): support Kotlin 2.4.20 (#1262)
---
.github/workflows/codeql.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index f1c19e7b..6c8edfc4 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -75,7 +75,7 @@ jobs:
with:
languages: ${{ matrix.language }}
build-mode: manual
- tools: https://github.com/github/codeql-action/releases/download/codeql-bundle-v2.26.2/codeql-bundle-linux64.tar.gz
+ tools: https://github.com/github/codeql-action/releases/download/codeql-bundle-v2.27.1/codeql-bundle-linux64.tar.gz
- name: Build Codebase (Compile Only)
run: |
From 28ca25d6c59215e802be400469634fad7534e72d Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 25 Sep 2026 15:31:12 +0300
Subject: [PATCH 009/107] chore: bump kotlin from 2.4.10 to 2.4.20 (#1083)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
---
.../ui/chat/SlashCommandDispatchRpcTest.kt | 12 ++++++++++++
gradle/libs.versions.toml | 2 +-
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt
index 2b28ef68..60cb31a2 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt
@@ -3,6 +3,8 @@ package com.m57.hermescontrol.ui.chat
import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase
+import com.m57.hermescontrol.data.model.PaginationInfo
+import com.m57.hermescontrol.data.model.SessionMessagesResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.ConnectionStatus
@@ -11,6 +13,7 @@ import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
+import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
@@ -742,6 +745,15 @@ class SlashCommandDispatchRpcTest {
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
+ coEvery {
+ ApiClient.hermesApi.getSessionMessages("session-xyz", any(), any(), any(), any())
+ } returns
+ retrofit2.Response.success(
+ SessionMessagesResponse(
+ messages = emptyList(),
+ pagination = PaginationInfo(order = "latest"),
+ ),
+ )
vm.sendMessage("/init")
advanceUntilIdle()
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 892f140f..190d0cc2 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -14,7 +14,7 @@ coroutines = "1.11.0"
junit = "4.13.2"
junit5 = "6.1.3"
junitPlatform = "6.1.3"
-kotlin = "2.4.10"
+kotlin = "2.4.20"
# standalone per KSP 2.0+ guidance — NOT kotlinVersion-kspVersion; easy
# to miss during Kotlin upgrades since it no longer tracks the Kotlin version.
ksp = "2.3.12"
From 0e228dec18b2a1f8a81192df07be03a789fbbb19 Mon Sep 17 00:00:00 2001
From: m57 <41645758+Hy4ri@users.noreply.github.com>
Date: Fri, 25 Sep 2026 15:50:12 +0300
Subject: [PATCH 010/107] fix(chat): preserve local command transcript anchor
(#1261)
---
.../com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt
index a04c7951..474b164b 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt
@@ -537,7 +537,11 @@ private fun List.inTranscriptOrder(
Long.MAX_VALUE
} else {
precedingCanonical?.takeIf { it >= 0L }
- ?: latestCanonical
+ ?: if (message.role == MessageRole.USER) {
+ precedingCanonical ?: latestCanonical
+ } else {
+ latestCanonical
+ }
}
if (hasPendingPredecessor) pendingOrderByLocal[message.id] = pendingLocalOrder ?: Long.MAX_VALUE
} else if (message.isHistoricalCache || message.isRestoredUnconfirmed) {
From ae9003decdaf1aa446476ea1f504f2f4a6ba7f90 Mon Sep 17 00:00:00 2001
From: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com>
Date: Fri, 25 Sep 2026 10:43:52 -0400
Subject: [PATCH 011/107] feat(#1247): hold-to-record voice notes with
server-side transcription (#1250)
Co-authored-by: M57 <41645758+Hy4ri@users.noreply.github.com>
---
.../chat/components/ComposerReadinessTest.kt | 8 +
.../data/model/AudioTranscription.kt | 30 ++
.../hermescontrol/data/remote/ApiClient.kt | 84 +++-
.../data/remote/HermesApiService.kt | 12 +
.../data/remote/ProfileScopeInterceptor.kt | 7 +
.../data/repository/VoiceNoteRepository.kt | 98 ++++
.../m57/hermescontrol/ui/chat/ChatScreen.kt | 13 +-
.../hermescontrol/ui/chat/ChatViewModel.kt | 126 +++++
.../ui/chat/VoiceNoteRecorder.kt | 120 +++++
.../ui/chat/components/ChatComposer.kt | 114 +++--
.../ui/chat/components/ChatMediaLaunchers.kt | 135 +++++-
.../ui/chat/components/ComposerToolbar.kt | 209 ++++++++-
.../components/VoiceNoteRecordingPanel.kt | 128 ++++++
app/src/main/res/values/strings.xml | 3 +
.../remote/ProfileScopeInterceptorTest.kt | 26 ++
.../repository/VoiceNoteRepositoryTest.kt | 133 ++++++
.../ui/chat/ChatUpdateCommandTest.kt | 9 +
.../ui/chat/ChatViewModelTest.kt | 434 ++++++++++++++++++
.../ui/chat/SlashCommandDispatchRpcTest.kt | 19 +-
19 files changed, 1619 insertions(+), 89 deletions(-)
create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/AudioTranscription.kt
create mode 100644 app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt
create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/VoiceNoteRecorder.kt
create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt
create mode 100644 app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt
index 796c9a5e..fc075cf0 100644
--- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt
@@ -34,6 +34,7 @@ class ComposerReadinessTest {
onMicTap = {},
isListening = false,
isAgentTyping = true,
+ canInterrupt = ready.value,
isConnected = true,
commandCatalog = CommandCatalog(),
isSessionReady = ready.value,
@@ -42,13 +43,20 @@ class ComposerReadinessTest {
compose.onNodeWithTag("chat_input").performTextInput("held draft")
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
compose.onNodeWithTag("chat_session_preparing").assertExists()
+ // Nothing to interrupt while the session prepares: the action button
+ // must not offer Stop (review, PR #1250).
+ compose.onNodeWithTag("stop_button").assertDoesNotExist()
compose.onNodeWithTag("send_button").assertIsNotEnabled().performClick()
compose.runOnIdle { assertEquals(0, sends) }
compose.runOnIdle { ready.value = true }
compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist()
+ // Ready + typing = an interruptible generation: Stop replaces the
+ // action glyph while queue-send stays available in the flat slot.
+ compose.onNodeWithTag("stop_button").assertExists()
compose.onNodeWithTag("send_button").assertIsEnabled().performClick()
compose.runOnIdle { assertEquals(1, sends) }
compose.runOnIdle { ready.value = false }
+ compose.onNodeWithTag("stop_button").assertDoesNotExist()
compose.onNodeWithTag("send_button").assertIsNotEnabled()
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/AudioTranscription.kt b/app/src/main/java/com/m57/hermescontrol/data/model/AudioTranscription.kt
new file mode 100644
index 00000000..a260b2d0
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/data/model/AudioTranscription.kt
@@ -0,0 +1,30 @@
+package com.m57.hermescontrol.data.model
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Request body for `POST /api/audio/transcribe` — the dashboard's server-side
+ * transcription relay, the same endpoint the desktop client's voice path uses.
+ *
+ * The recording rides inline as a base64 `data:` URL (the desktop wire shape).
+ * [OkHttpProvider.json] carries [kotlinx.serialization.json.JsonNamingStrategy.SnakeCase],
+ * so [dataUrl]/[mimeType] serialize as `data_url`/`mime_type`.
+ */
+@Serializable
+data class AudioTranscriptionRequest(
+ val dataUrl: String,
+ val mimeType: String,
+)
+
+/**
+ * Response of `POST /api/audio/transcribe`.
+ *
+ * [transcript] is empty (not an error) when the server detects no speech in
+ * the recording.
+ */
+@Serializable
+data class AudioTranscriptionResponse(
+ val ok: Boolean = false,
+ val transcript: String? = null,
+ val provider: String? = null,
+)
diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt
index e413181b..369d9a43 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt
@@ -4,9 +4,11 @@ import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
+import java.util.concurrent.TimeUnit
/**
* Provides a Retrofit-backed [HermesApiService].
@@ -22,6 +24,9 @@ object ApiClient {
@Volatile
private var service: HermesApiService? = null
+ @Volatile
+ private var okHttpClient: OkHttpClient? = null
+
/** The current [HermesApiService] instance. Lazily created on first access. */
val hermesApi: HermesApiService
get() {
@@ -39,6 +44,7 @@ object ApiClient {
synchronized(this) {
retrofit = null
service = null
+ okHttpClient = null
}
}
@@ -81,6 +87,64 @@ object ApiClient {
return tempRetrofit.create(HermesApiService::class.java)
}
+ /**
+ * Dedicated [HermesApiService] for the blocking voice-note transcription
+ * POST (`/api/audio/transcribe`).
+ *
+ * STT blocks until the profile's provider finishes, so it cannot run on
+ * the shared 30s client, and a re-submitted POST would repeat expensive
+ * provider work after the request may already have reached the server
+ * (the repository also passes `retries = 0`). The returned service reuses
+ * the shared connection pool and the auth/profile interceptor stack, and
+ * applies [readTimeoutMs] to reads and writes with transport retries
+ * disabled (review, PR #1250).
+ */
+ fun transcriptionService(readTimeoutMs: Long): HermesApiService {
+ val client =
+ OkHttpProvider
+ .base
+ .newBuilder()
+ .addInterceptor(restAuthInterceptor())
+ .addInterceptor(ProfileScopeInterceptor)
+ .authenticator(TokenRefreshAuthenticator)
+ .readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
+ .writeTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
+ .retryOnConnectionFailure(false)
+ .build()
+ return Retrofit
+ .Builder()
+ .baseUrl(AuthManager.endpointForBuild().baseUrl)
+ .client(client)
+ .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
+ .build()
+ .create(HermesApiService::class.java)
+ }
+
+ /**
+ * REST auth stamping shared by every client: token mode sends the bearer
+ * header; gated (basic-auth cookie) mode leaves the request alone because
+ * the shared CookieManager jar carries the session cookie and a stray
+ * Authorization header would 401 (issue #470).
+ */
+ private fun restAuthInterceptor(): Interceptor =
+ Interceptor { chain ->
+ val request = chain.request()
+ if (AuthManager.isGatedMode()) {
+ return@Interceptor chain.proceed(request)
+ }
+ val token = AuthManager.getToken()
+ if (!token.isNullOrBlank()) {
+ chain.proceed(
+ request
+ .newBuilder()
+ .addHeader("Authorization", "Bearer $token")
+ .build(),
+ )
+ } else {
+ chain.proceed(request)
+ }
+ }
+
// ── Internal ─────────────────────────────────────────────────────────
private fun buildService(): HermesApiService =
@@ -111,25 +175,7 @@ object ApiClient {
// must NOT stamp one here — otherwise every REST tab (skills, cron,
// config, ...) fails with "token expired" while the WS chat, which
// authenticates via ?ticket=, keeps working.
- val authInterceptor =
- Interceptor { chain ->
- val request = chain.request()
- if (AuthManager.isGatedMode()) {
- // Cookie in the shared jar is the only valid REST credential.
- return@Interceptor chain.proceed(request)
- }
- val token = AuthManager.getToken()
- if (!token.isNullOrBlank()) {
- chain.proceed(
- request
- .newBuilder()
- .addHeader("Authorization", "Bearer $token")
- .build(),
- )
- } else {
- chain.proceed(request)
- }
- }
+ val authInterceptor = restAuthInterceptor()
val okHttp =
OkHttpProvider
diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt
index bbaf3811..55e6cddf 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt
@@ -7,6 +7,8 @@ import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.AddMcpServerRequest
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.AnalyticsResponse
+import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
+import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
import com.m57.hermescontrol.data.model.BackupTriggerRequest
import com.m57.hermescontrol.data.model.BulkDeleteRequest
@@ -1030,4 +1032,14 @@ interface HermesApiService : KanbanApiService {
suspend fun deleteManagedFile(
@Body body: ManagedFileDelete,
): Response
+
+ // ── Audio: server-side transcription ────────────────────────────────
+ // The dashboard's voice relay (hermes_cli/web_routers/audio.py) — the
+ // same endpoint the desktop client's voice notes use. The clip rides
+ // inline as a base64 data URL; the server resolves STT through the
+ // active profile's configured provider.
+ @POST("api/audio/transcribe")
+ suspend fun transcribeAudio(
+ @Body body: AudioTranscriptionRequest,
+ ): Response
}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt
index 497a5a63..3c407a83 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt
@@ -26,6 +26,13 @@ object ProfileScopeInterceptor : Interceptor {
private val PROFILE_SCOPED_PREFIXES =
listOf(
"/api/analytics",
+ // Voice-note transcription resolves STT through the profile's
+ // configured provider, so the request must carry the active
+ // profile (the desktop sends this route `...profileScoped()`;
+ // hermes_cli/web_routers/audio.py is config-scoped). Unscoped, a
+ // multi-profile host transcribes under the launch profile
+ // (review, PR #1250).
+ "/api/audio",
"/api/config",
"/api/credentials/pool",
"/api/cron",
diff --git a/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt
new file mode 100644
index 00000000..3dabae8a
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt
@@ -0,0 +1,98 @@
+package com.m57.hermescontrol.data.repository
+
+import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
+import com.m57.hermescontrol.data.remote.ApiClient
+import com.m57.hermescontrol.data.remote.HermesApiService
+import com.m57.hermescontrol.data.remote.NetworkError
+import com.m57.hermescontrol.data.remote.NetworkResult
+import com.m57.hermescontrol.data.remote.safeApiCall
+import java.io.File
+import java.io.IOException
+import java.util.Base64
+
+/**
+ * MIME type of the recorder's output: MPEG-4 container with AAC audio — see
+ * `VoiceNoteRecorder` in the chat UI layer.
+ */
+const val VOICE_NOTE_MIME_TYPE = "audio/mp4"
+
+/**
+ * Transcribes a recorded voice note through the dashboard's server-side
+ * transcription endpoint (`POST /api/audio/transcribe`).
+ *
+ * The wire shape is the desktop client's: base64 `data:` URL plus MIME type.
+ * The server resolves STT through the active profile's configured provider
+ * (the request is profile-scoped by ProfileScopeInterceptor, matching the
+ * desktop's `...profileScoped()` on this route), so the transcript matches
+ * the rest of Hermes instead of the phone's on-device recognizer.
+ *
+ * Two contracts differ from a plain REST call (review, PR #1250):
+ * - [serviceProvider] resolves the Retrofit service at CALL time, so a
+ * connection/profile switch (ApiClient.rebuild) applies to the next
+ * transcription instead of pinning the construction-time service.
+ * - The STT POST runs on a dedicated long-timeout client with `retries = 0`:
+ * re-submitting after a timeout or 5xx would repeat expensive provider
+ * work although the request may already have reached the server.
+ *
+ * Open for test substitution (mirrors [ChatPersistenceRepository]'s pattern).
+ */
+open class VoiceNoteRepository(
+ private val serviceProvider: (timeoutMs: Long) -> HermesApiService = { timeoutMs ->
+ ApiClient.transcriptionService(timeoutMs)
+ },
+) {
+ open suspend fun transcribe(file: File): NetworkResult {
+ val encoded =
+ try {
+ file.readBytes()
+ } catch (e: IOException) {
+ // A local read failure must return a Failure instead of
+ // escaping as an exception — the caller resets its
+ // transcription state from this result (review, PR #1250).
+ return NetworkResult.Failure(
+ NetworkError.Unknown("Failed to read the voice note: ${e.message}", e),
+ )
+ }
+ val dataUrl = "data:$VOICE_NOTE_MIME_TYPE;base64," + Base64.getEncoder().encodeToString(encoded)
+ val result =
+ safeApiCall(retries = 0) {
+ serviceProvider(transcriptionTimeoutMs(dataUrl)).transcribeAudio(
+ AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE),
+ )
+ }
+ return when (result) {
+ is NetworkResult.Success -> {
+ NetworkResult.Success(
+ result.data
+ ?.transcript
+ .orEmpty()
+ .trim(),
+ )
+ }
+
+ is NetworkResult.Failure -> {
+ result
+ }
+ }
+ }
+
+ companion object {
+ /** Floor for STT requests — desktop parity (`AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS`). */
+ const val MIN_REQUEST_TIMEOUT_MS = 180_000L
+
+ /** Cap so a hung transcription cannot pin the call forever — desktop parity. */
+ const val MAX_REQUEST_TIMEOUT_MS = 600_000L
+
+ private const val BASE64_CHARS_PER_TIMEOUT_MS = 10L
+
+ /**
+ * Timeout for the blocking STT endpoint, mirroring the desktop's
+ * `audioTranscribeRequestTimeoutMs`: ~0.1ms of budget per base64 char
+ * keeps short clips at the floor while multi-minute recordings scale
+ * toward the cap. Integer ceil() keeps the arithmetic exact.
+ */
+ fun transcriptionTimeoutMs(dataUrl: String): Long =
+ ((dataUrl.length + BASE64_CHARS_PER_TIMEOUT_MS - 1) / BASE64_CHARS_PER_TIMEOUT_MS)
+ .coerceIn(MIN_REQUEST_TIMEOUT_MS, MAX_REQUEST_TIMEOUT_MS)
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt
index 3dec359f..ee9d91da 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt
@@ -440,12 +440,16 @@ fun ChatScreen(
onAddAttachments = { attachments ->
viewModel.addAttachments(attachments)
},
+ onVoiceNoteRecorded = { file ->
+ viewModel.sendVoiceNote(file)
+ },
onShowMessage = { msg ->
scrollScope.launch {
snackbarHostState.showSnackbar(msg)
}
},
launchExternalActivity = launchExternalActivity,
+ isTranscribingVoiceNote = state.isTranscribingVoiceNote,
context = context,
)
@@ -901,9 +905,16 @@ fun ChatScreen(
}
},
onMicTap = mediaLaunchers.onMicTap,
- isListening = mediaLaunchers.isListening,
+ onMicHoldStart = mediaLaunchers.onMicHoldStart,
+ onMicHoldEnd = mediaLaunchers.onMicHoldEnd,
+ onMicHoldCancel = mediaLaunchers.onMicHoldCancel,
+ isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote,
+ isRecordingVoice = mediaLaunchers.isRecordingVoice,
+ voiceNoteAmplitude = mediaLaunchers.voiceNoteAmplitude,
+ onStopGeneration = { viewModel.interruptSession() },
isAgentTyping = state.isAgentTyping,
isMainTurnBusy = state.isMainTurnBusy,
+ canInterrupt = state.canInterrupt,
isConnected = state.isConnected,
isSessionReady = state.isSessionReady && !timelineState.isHistorical,
sessionPreparationFailed = state.resumeError != null,
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
index 5d6ddc93..12832256 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
@@ -6,6 +6,7 @@ import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
+import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.local.SlashUsageStore
import com.m57.hermescontrol.data.model.Attachment
@@ -22,6 +23,7 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall
+import com.m57.hermescontrol.data.repository.VoiceNoteRepository
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.CommandBlocklist
@@ -66,6 +68,11 @@ private const val TAG = "ChatViewModel"
private const val MESSAGE_PAGE_SIZE = 150
private const val TIMELINE_PAGE_SIZE = 500
private const val HISTORY_WINDOW_SIZE = 120
+private const val VOICE_NOTE_OFFLINE_MESSAGE = "Voice note not sent — not connected"
+private const val VOICE_NOTE_EMPTY_MESSAGE = "No speech detected in the voice note"
+private const val VOICE_NOTE_FAILED_MESSAGE = "Voice note transcription failed"
+private const val VOICE_NOTE_UNSENT_MESSAGE =
+ "Voice note not sent — transcript kept in the input field"
private val REASONING_EFFORT_LEVELS =
setOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
@@ -135,6 +142,8 @@ data class ChatUiState(
val pendingSends: List = emptyList(),
val isThinking: Boolean = false,
val thinkingText: String = "",
+ /** True while a recorded voice note uploads for server-side transcription. */
+ val isTranscribingVoiceNote: Boolean = false,
val isLoading: Boolean = false,
val isLoadingOlder: Boolean = false,
val hasOlderMessages: Boolean = false,
@@ -255,6 +264,15 @@ data class ChatUiState(
) {
/** Convenience — derived from [connectionStatus]. */
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
+
+ /**
+ * True only while a generation can actually be interrupted: typing can
+ * start during session preparation, before any runtime session exists,
+ * and `session.interrupt` has nothing to stop then. The composer derives
+ * its Stop affordance from this instead of [isAgentTyping] alone
+ * (review, PR #1250).
+ */
+ val canInterrupt: Boolean get() = isAgentTyping && isSessionReady
}
data class ChatTimelineState(
@@ -414,6 +432,7 @@ class ChatViewModel(
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
private val historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = searchDispatcher,
private val sendStore: ChatSendStore = ChatSendStore(application),
+ private val voiceNoteRepository: VoiceNoteRepository = VoiceNoteRepository(),
) : AndroidViewModel(application) {
constructor(application: Application) : this(application, startCleanup = true)
@@ -2874,6 +2893,113 @@ class ChatViewModel(
return true
}
+ /** True while a voice-note transcription owns the mic-to-send pipeline. */
+ private var voiceNoteTranscriptionInFlight = false
+
+ /**
+ * Transcribe a recorded voice note through the dashboard's server-side
+ * STT relay (`POST /api/audio/transcribe` — the desktop client's voice
+ * path) and submit the transcript as the next message. The profile's
+ * configured STT provider answers, not the phone's on-device recognizer.
+ *
+ * The recording belongs to the session that was active when it was taken:
+ * transcription takes long enough for the socket, the profile, or the user
+ * to move on, so a transcript that can no longer land in that session or
+ * connection/profile scope is preserved in the composer instead of being
+ * dropped. Only one
+ * transcription runs at a time — [voiceNoteTranscriptionInFlight] keeps
+ * every branch below single-flight (review, PR #1250).
+ */
+ fun sendVoiceNote(file: File) {
+ if (voiceNoteTranscriptionInFlight) {
+ file.delete()
+ return
+ }
+ if (!canSubmitMessage()) {
+ file.delete()
+ _uiState.update { it.copy(errorMessage = VOICE_NOTE_OFFLINE_MESSAGE) }
+ return
+ }
+ val recordedSessionId = _uiState.value.currentSessionId
+ val recordedRuntimeSessionId = runtimeSessionId
+ // The recording also belongs to the connection/profile scope it was
+ // taken on: both session IDs can still be null/null across a scope
+ // switch while a replacement session create is pending, so the data
+ // scope is the deciding identity for that window (review, PR #1250).
+ val recordedDataScope = AuthManager.currentDataScope()
+ voiceNoteTranscriptionInFlight = true
+ viewModelScope.launch {
+ _uiState.update { it.copy(isTranscribingVoiceNote = true) }
+ try {
+ val result =
+ withContext(ioDispatcher) {
+ try {
+ voiceNoteRepository.transcribe(file)
+ } finally {
+ file.delete()
+ }
+ }
+ when (result) {
+ is NetworkResult.Success -> {
+ val transcript = result.data.trim()
+ if (transcript.isEmpty()) {
+ _uiState.update { it.copy(errorMessage = VOICE_NOTE_EMPTY_MESSAGE) }
+ } else {
+ deliverVoiceTranscript(
+ transcript = transcript,
+ recordedSessionId = recordedSessionId,
+ recordedRuntimeSessionId = recordedRuntimeSessionId,
+ recordedDataScope = recordedDataScope,
+ )
+ }
+ }
+
+ is NetworkResult.Failure -> {
+ Log.w(TAG, "Voice note transcription failed: ${result.error.message}")
+ _uiState.update {
+ it.copy(errorMessage = "$VOICE_NOTE_FAILED_MESSAGE: ${result.error.message}")
+ }
+ }
+ }
+ } finally {
+ // Always release the single-flight flag and the composer's
+ // transcription state, even if a branch above throws.
+ voiceNoteTranscriptionInFlight = false
+ _uiState.update { it.copy(isTranscribingVoiceNote = false) }
+ }
+ }
+ }
+
+ /**
+ * Submits a transcribed voice note, or keeps the transcript in the input
+ * field when it can no longer land where it was recorded. The recording
+ * file is already deleted by now, so a rejected send restores the text
+ * instead of losing it.
+ */
+ private fun deliverVoiceTranscript(
+ transcript: String,
+ recordedSessionId: String?,
+ recordedRuntimeSessionId: String?,
+ recordedDataScope: DataScope?,
+ ) {
+ val sessionChanged =
+ _uiState.value.currentSessionId != recordedSessionId ||
+ runtimeSessionId != recordedRuntimeSessionId
+ // A connection/profile switch can leave the session IDs null on both
+ // sides of the recording (a replacement session create may still be
+ // pending), so the data scope is the ownership check that catches
+ // that move (review, PR #1250).
+ val scopeChanged = AuthManager.currentDataScope() != recordedDataScope
+ if (sessionChanged || scopeChanged || !sendMessage(transcript)) {
+ _uiState.update {
+ it.copy(
+ composerTextToRestore = transcript,
+ errorMessage = VOICE_NOTE_UNSENT_MESSAGE,
+ )
+ }
+ }
+ }
+
private fun canSubmitMessage(): Boolean =
_uiState.value.pendingReasoningLevel == null &&
wsClient.connectionStatus.value == ConnectionStatus.CONNECTED &&
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/VoiceNoteRecorder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/VoiceNoteRecorder.kt
new file mode 100644
index 00000000..66e4b5e6
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/VoiceNoteRecorder.kt
@@ -0,0 +1,120 @@
+package com.m57.hermescontrol.ui.chat
+
+import android.content.Context
+import android.media.MediaRecorder
+import android.os.Build
+import android.os.SystemClock
+import android.util.Log
+import java.io.File
+
+/**
+ * Records a hold-to-talk voice note as an MPEG-4/AAC file in the app cache.
+ *
+ * One instance drives one in-flight recording; the call site owns UI state and
+ * decides what to do with the clip. 16 kHz mono AAC keeps the payload small
+ * (roughly 0.5 MB at [MAX_DURATION_MS]) so it uploads quickly to the
+ * dashboard's transcription endpoint.
+ */
+class VoiceNoteRecorder(
+ private val context: Context,
+) {
+ companion object {
+ private const val TAG = "VoiceNoteRecorder"
+
+ /** Clips shorter than this are treated as accidental and discarded. */
+ const val MIN_DURATION_MS = 500L
+
+ /**
+ * Hard cap. The call site (ChatMediaLaunchers) owns the timer and
+ * drives the single stop path — the recorder itself no longer stops
+ * recording, so MediaRecorder's auto-stop cannot race the
+ * hold-release stop (review, PR #1250).
+ */
+ const val MAX_DURATION_MS = 120_000
+ }
+
+ private var recorder: MediaRecorder? = null
+ private var outputFile: File? = null
+ private var startedAtMs = 0L
+
+ val isActive: Boolean
+ get() = recorder != null
+
+ /** Starts recording. Returns false when the microphone is unavailable. */
+ fun start(): Boolean {
+ if (recorder != null) return false
+ val file =
+ runCatching { File.createTempFile("voice_", ".m4a", context.cacheDir) }
+ .getOrNull()
+ ?: return false
+ return try {
+ val mediaRecorder =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaRecorder(context)
+ } else {
+ @Suppress("DEPRECATION")
+ MediaRecorder()
+ }
+ mediaRecorder.apply {
+ setAudioSource(MediaRecorder.AudioSource.MIC)
+ setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
+ setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
+ setAudioSamplingRate(16_000)
+ setAudioEncodingBitRate(32_000)
+ setAudioChannels(1)
+ setOutputFile(file.absolutePath)
+ prepare()
+ start()
+ }
+ recorder = mediaRecorder
+ outputFile = file
+ startedAtMs = SystemClock.elapsedRealtime()
+ Log.i(TAG, "Voice note recording started: ${file.name}")
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start voice note recording", e)
+ releaseQuietly()
+ file.delete()
+ false
+ }
+ }
+
+ /**
+ * Stops recording and returns the clip when it is long enough to send.
+ * Returns null for too-short or failed recordings — the temp file is
+ * removed in both cases.
+ */
+ fun stop(): File? {
+ val active = recorder ?: return null
+ val file = outputFile
+ val durationMs = SystemClock.elapsedRealtime() - startedAtMs
+ val stopped = runCatching { active.stop() }.isSuccess
+ releaseQuietly()
+ val byteCount = file?.length() ?: -1L
+ Log.i(TAG, "Voice note stop: durationMs=$durationMs stopped=$stopped bytes=$byteCount")
+ if (!stopped || file == null || durationMs < MIN_DURATION_MS) {
+ file?.delete()
+ return null
+ }
+ return file
+ }
+
+ /** Aborts the recording and deletes the clip. */
+ fun cancel() {
+ if (recorder != null) {
+ runCatching { recorder?.stop() }
+ }
+ releaseQuietly()
+ outputFile?.delete()
+ }
+
+ /** Peak amplitude of the current recording window — 0 when idle. */
+ fun currentAmplitude(): Int = runCatching { recorder?.maxAmplitude ?: 0 }.getOrDefault(0)
+
+ private fun releaseQuietly() {
+ runCatching { recorder?.release() }
+ recorder = null
+ outputFile = null
+ startedAtMs = 0L
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt
index 331528d1..ea3651f2 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt
@@ -39,6 +39,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -83,6 +84,7 @@ fun ChatInputBar(
isListening: Boolean,
isAgentTyping: Boolean,
isMainTurnBusy: Boolean = false,
+ canInterrupt: Boolean = false,
isConnected: Boolean,
commandCatalog: CommandCatalog,
isSessionReady: Boolean,
@@ -108,6 +110,12 @@ fun ChatInputBar(
showModelProvider: Boolean = false,
reasoningWireLevel: String? = null,
pendingReasoningLevel: String? = null,
+ onMicHoldStart: () -> Unit = {},
+ onMicHoldEnd: () -> Unit = {},
+ onMicHoldCancel: () -> Unit = {},
+ isRecordingVoice: Boolean = false,
+ voiceNoteAmplitude: State = remember { mutableStateOf(0f) },
+ onStopGeneration: () -> Unit = {},
) {
// Allow sending while the agent is mid-turn or awaiting approval: the
// gateway's prompt.submit busy-input policy queues it as the next turn
@@ -269,55 +277,64 @@ fun ChatInputBar(
}
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
- CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
- BasicTextField(
- value = inputFieldValue,
- onValueChange = onInputChange,
- modifier =
- Modifier
- .weight(1f)
- .heightIn(min = 42.dp, max = 200.dp)
- .padding(vertical = 4.dp)
- .testTag("chat_input"),
- enabled = isConnected,
- textStyle =
- MaterialTheme.typography.bodyLarge.copy(
- color = palette.text,
- textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
- textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
- ),
- singleLine = false,
- maxLines = 8,
- cursorBrush = SolidColor(palette.text),
- decorationBox = { innerTextField ->
- CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
- Box(
- modifier = Modifier.fillMaxWidth(),
- contentAlignment =
- if (isInputRtl) {
- Alignment.CenterEnd
- } else {
- Alignment.CenterStart
- },
- ) {
- CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
- if (inputFieldValue.text.isEmpty()) {
- Text(
- text = placeholderText,
- style = MaterialTheme.typography.bodyLarge,
- textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
- color = palette.placeholder,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.fillMaxWidth(),
- )
+ if (isRecordingVoice) {
+ VoiceNoteRecordingPanel(
+ amplitude = voiceNoteAmplitude,
+ modifier = Modifier.weight(1f),
+ )
+ } else {
+ CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
+ BasicTextField(
+ value = inputFieldValue,
+ onValueChange = onInputChange,
+ modifier =
+ Modifier
+ .weight(1f)
+ .heightIn(min = 42.dp, max = 200.dp)
+ .padding(vertical = 4.dp)
+ .testTag("chat_input"),
+ enabled = isConnected,
+ textStyle =
+ MaterialTheme.typography.bodyLarge.copy(
+ color = palette.text,
+ textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
+ textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
+ ),
+ singleLine = false,
+ maxLines = 8,
+ cursorBrush = SolidColor(palette.text),
+ decorationBox = { innerTextField ->
+ CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment =
+ if (isInputRtl) {
+ Alignment.CenterEnd
+ } else {
+ Alignment.CenterStart
+ },
+ ) {
+ CompositionLocalProvider(
+ LocalLayoutDirection provides inputLayoutDirection,
+ ) {
+ if (inputFieldValue.text.isEmpty()) {
+ Text(
+ text = placeholderText,
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
+ color = palette.placeholder,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ innerTextField()
}
- innerTextField()
}
}
- }
- },
- )
+ },
+ )
+ }
}
}
@@ -333,10 +350,15 @@ fun ChatInputBar(
onSend = onSend,
onQueue = { onBusySend(BusySendMode.QUEUE) },
onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) },
+ canInterrupt = canInterrupt,
+ onStopGeneration = onStopGeneration,
onAttachTap = { showAttachmentTray = !showAttachmentTray },
onModelTap = onModelTap,
onReasoningSelected = onReasoningTap,
onMicTap = onMicTap,
+ onMicHoldStart = onMicHoldStart,
+ onMicHoldEnd = onMicHoldEnd,
+ onMicHoldCancel = onMicHoldCancel,
modifier = Modifier.testTag("chat_composer_toolbar"),
canDisableReasoning = canDisableReasoning,
supportsReasoning = supportsReasoning,
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt
index afb68002..fe8dd7d8 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt
@@ -12,6 +12,9 @@ import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -27,6 +30,8 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
+import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder
+import kotlinx.coroutines.delay
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
@@ -34,10 +39,15 @@ import java.util.Locale
class ChatMediaLaunchers(
val isListening: Boolean,
+ val isRecordingVoice: Boolean,
+ val voiceNoteAmplitude: State,
val onMicTap: () -> Unit,
val onCameraTap: () -> Unit,
val onImageTap: () -> Unit,
val onFileTap: () -> Unit,
+ val onMicHoldStart: () -> Unit = {},
+ val onMicHoldEnd: () -> Unit = {},
+ val onMicHoldCancel: () -> Unit = {},
)
@Composable
@@ -46,8 +56,10 @@ fun rememberChatMediaLaunchers(
onInputFieldValueChange: (TextFieldValue) -> Unit,
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
onAddAttachments: (List) -> Unit,
+ onVoiceNoteRecorded: (file: File) -> Unit,
onShowMessage: (String) -> Unit,
launchExternalActivity: (() -> Unit) -> Unit,
+ isTranscribingVoiceNote: Boolean,
context: Context = LocalContext.current,
): ChatMediaLaunchers {
var isListening by remember { mutableStateOf(false) }
@@ -57,6 +69,7 @@ fun rememberChatMediaLaunchers(
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
+ val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded)
val currentOnShowMessage by rememberUpdatedState(onShowMessage)
val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity)
@@ -64,6 +77,59 @@ fun rememberChatMediaLaunchers(
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
val cameraErrorMsg = stringResource(R.string.chat_camera_error)
+ val voiceRecordFailedMsg = stringResource(R.string.chat_voice_record_failed)
+
+ // Hold-to-record voice note recorder: the clip uploads to the dashboard
+ // for server-side transcription, unlike the flat tap path below, which
+ // still drives the on-device recognizer.
+ val voiceNoteRecorder = remember { VoiceNoteRecorder(context) }
+ var isRecordingVoice by remember { mutableStateOf(false) }
+
+ // Live mic level for the recording panel — rises fast, decays slowly so
+ // the meter reads as voice activity instead of flicker.
+ val voiceNoteAmplitude = remember { mutableStateOf(0f) }
+ LaunchedEffect(Unit) {
+ while (true) {
+ delay(90)
+ val previous = voiceNoteAmplitude.value
+ val next =
+ if (voiceNoteRecorder.isActive) {
+ maxOf(voiceNoteRecorder.currentAmplitude() / 32767f, previous * 0.8f)
+ } else {
+ 0f
+ }
+ if (next != previous) {
+ voiceNoteAmplitude.value = next
+ }
+ }
+ }
+
+ fun finishVoiceRecording() {
+ val recordedFile = voiceNoteRecorder.stop()
+ isRecordingVoice = false
+ if (recordedFile != null) {
+ currentOnVoiceNoteRecorded(recordedFile)
+ }
+ }
+
+ // App-owned max-duration cap: the timer drives the same
+ // finishVoiceRecording() path as the hold release, so exactly one code
+ // path ever calls stop() — MediaRecorder's own auto-stop used to race
+ // the release and discard a valid max-length clip (review, PR #1250).
+ LaunchedEffect(isRecordingVoice) {
+ if (isRecordingVoice) {
+ delay(VoiceNoteRecorder.MAX_DURATION_MS.toLong())
+ if (voiceNoteRecorder.isActive) {
+ finishVoiceRecording()
+ }
+ }
+ }
+
+ DisposableEffect(voiceNoteRecorder) {
+ onDispose {
+ voiceNoteRecorder.cancel()
+ }
+ }
// Speech-to-text recognition launcher (issue #194)
val speechLauncher =
@@ -117,6 +183,18 @@ fun rememberChatMediaLaunchers(
}
}
+ // Hold-to-record permission: unlike the launcher above, granting does NOT
+ // auto-start dictation — the user records by holding the mic again.
+ val voiceNotePermissionLauncher =
+ rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { granted ->
+ ExternalActivityLifecycleGuard.externalActivityReturned()
+ if (!granted) {
+ currentOnShowMessage(sttPermissionDeniedMsg)
+ }
+ }
+
// Multi-file picker for attachments (issue #195)
val filePickerLauncher =
rememberLauncherForActivityResult(
@@ -183,7 +261,14 @@ fun rememberChatMediaLaunchers(
}
val onMicTap: () -> Unit = {
- if (isListening) {
+ if (isTranscribingVoiceNote) {
+ // Single-flight: a server transcription owns the voice pipeline
+ // until it lands (review, PR #1250).
+ } else if (isRecordingVoice) {
+ // A tap while recording discards the in-flight voice note.
+ voiceNoteRecorder.cancel()
+ isRecordingVoice = false
+ } else if (isListening) {
isListening = false
} else if (
ContextCompat.checkSelfPermission(
@@ -212,6 +297,45 @@ fun rememberChatMediaLaunchers(
}
}
+ val onMicHoldStart: () -> Unit = {
+ if (isTranscribingVoiceNote) {
+ // Single-flight: no new recording while a transcription runs.
+ } else if (voiceNoteRecorder.isActive || isListening) {
+ // A voice note or a dictation session is already running.
+ } else if (
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.RECORD_AUDIO,
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ if (voiceNoteRecorder.start()) {
+ isRecordingVoice = true
+ } else {
+ currentOnShowMessage(voiceRecordFailedMsg)
+ }
+ } else {
+ // The denial message comes from voiceNotePermissionLauncher's
+ // result callback only — showing it here fired it before the
+ // dialog even opened (review, PR #1250).
+ currentLaunchExternalActivity {
+ voiceNotePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
+ }
+ }
+ }
+
+ val onMicHoldEnd: () -> Unit = {
+ if (voiceNoteRecorder.isActive) {
+ finishVoiceRecording()
+ }
+ }
+
+ val onMicHoldCancel: () -> Unit = {
+ if (voiceNoteRecorder.isActive) {
+ voiceNoteRecorder.cancel()
+ }
+ isRecordingVoice = false
+ }
+
val onCameraTap: () -> Unit = {
try {
val timeStamp =
@@ -245,13 +369,18 @@ fun rememberChatMediaLaunchers(
}
}
- return remember(isListening) {
+ return remember(isListening, isRecordingVoice, isTranscribingVoiceNote) {
ChatMediaLaunchers(
- isListening = isListening,
+ isListening = isListening || isRecordingVoice,
+ isRecordingVoice = isRecordingVoice,
+ voiceNoteAmplitude = voiceNoteAmplitude,
onMicTap = onMicTap,
onCameraTap = onCameraTap,
onImageTap = onImageTap,
onFileTap = onFileTap,
+ onMicHoldStart = onMicHoldStart,
+ onMicHoldEnd = onMicHoldEnd,
+ onMicHoldCancel = onMicHoldCancel,
)
}
}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt
index 943967ef..9fcd992b 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt
@@ -10,6 +10,8 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -51,12 +53,16 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.scale
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
@@ -67,6 +73,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
+import kotlinx.coroutines.withTimeoutOrNull
/**
* Bottom controls row for the chat composer, rendered inside the composer card.
@@ -116,12 +123,50 @@ fun ComposerToolbar(
reasoningWireLevel: String? = null,
pendingReasoningLevel: String? = null,
isSessionReady: Boolean = true,
+ canInterrupt: Boolean = false,
+ onMicHoldStart: () -> Unit = {},
+ onMicHoldEnd: () -> Unit = {},
+ onMicHoldCancel: () -> Unit = {},
+ onStopGeneration: () -> Unit = {},
) {
var showReasoningMenu by remember { mutableStateOf(false) }
val palette = composerPalette()
val reasoningDisabledForModel = supportsReasoning == false
val canDisable = canDisableReasoning
+ // Telegram-style voice notes: hold to record, release to send, slide away
+ // to cancel. Both mic controls run the same single pointer loop
+ // (micHoldHandler), which owns the whole press until finger-up — so the
+ // release that submits the note can never be lost between recompositions.
+ val currentIsConnected = rememberUpdatedState(isConnected)
+ val currentShowSend = rememberUpdatedState(showSend)
+ val currentCanInterrupt = rememberUpdatedState(canInterrupt)
+ val currentOnMicHoldStart = rememberUpdatedState(onMicHoldStart)
+ val currentOnMicHoldEnd = rememberUpdatedState(onMicHoldEnd)
+ val currentOnMicHoldCancel = rememberUpdatedState(onMicHoldCancel)
+ val flatMicGesture =
+ Modifier.pointerInput(Unit) {
+ micHoldHandler(
+ isEnabled = {
+ currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value
+ },
+ onHoldStart = { currentOnMicHoldStart.value() },
+ onHoldEnd = { currentOnMicHoldEnd.value() },
+ onHoldCancel = { currentOnMicHoldCancel.value() },
+ )
+ }
+ val actionMicGesture =
+ Modifier.pointerInput(Unit) {
+ micHoldHandler(
+ isEnabled = {
+ currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value
+ },
+ onHoldStart = { currentOnMicHoldStart.value() },
+ onHoldEnd = { currentOnMicHoldEnd.value() },
+ onHoldCancel = { currentOnMicHoldCancel.value() },
+ )
+ }
+
Row(
modifier =
modifier
@@ -417,25 +462,47 @@ fun ComposerToolbar(
}
}
- // Flat mic / stop button — only while the action button is in send mode
+ // Flat slot — mic while idle; while a generation can be interrupted it
+ // keeps queue-send available (the action button carries Stop). During
+ // session preparation the mic stays here and the action button is a
+ // disabled send: Stop must not appear when there is nothing to
+ // interrupt (review, PR #1250).
AnimatedVisibility(
visible = showSend,
enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut(targetScale = 0.8f),
) {
- FilledIconButton(
- onClick = onMicTap,
- enabled = isConnected,
- colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette),
- modifier =
- Modifier
- .size(ControlSize)
- .testTag(if (isListening) "mic_stop_button" else "mic_button"),
- ) {
- Icon(
- imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic,
- contentDescription = if (isListening) "Stop listening" else "Mic",
- )
+ if (canInterrupt) {
+ FilledIconButton(
+ onClick = onSend,
+ enabled = canSend,
+ colors = flatIconButtonColors(palette),
+ modifier =
+ Modifier
+ .size(ControlSize)
+ .testTag("send_button"),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Send,
+ contentDescription = stringResource(R.string.chat_send_desc),
+ )
+ }
+ } else {
+ FilledIconButton(
+ onClick = onMicTap,
+ enabled = isConnected,
+ colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette),
+ modifier =
+ Modifier
+ .size(ControlSize)
+ .testTag(if (isListening) "mic_stop_button" else "mic_button")
+ .then(flatMicGesture),
+ ) {
+ Icon(
+ imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic,
+ contentDescription = if (isListening) "Stop listening" else "Mic",
+ )
+ }
}
}
@@ -484,21 +551,29 @@ fun ComposerToolbar(
}
}.combinedClickable(
enabled = actionEnabled,
- onClick = if (showSend) onSend else onMicTap,
+ onClick = {
+ when {
+ canInterrupt -> onStopGeneration()
+ showSend -> onSend()
+ else -> onMicTap()
+ }
+ },
onLongClick = if (showSend && showQueue) onStopAndSend else null,
onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null,
).testTag(
when {
+ canInterrupt -> "stop_button"
showSend -> "send_button"
isListening -> "mic_stop_button"
else -> "mic_button"
},
- ),
+ ).then(if (showSend || canInterrupt) Modifier else actionMicGesture),
contentAlignment = Alignment.Center,
) {
Crossfade(
targetState =
when {
+ canInterrupt -> ActionGlyph.STOP
showSend -> ActionGlyph.SEND
isListening -> ActionGlyph.STOP
else -> ActionGlyph.VOICE
@@ -517,8 +592,18 @@ fun ComposerToolbar(
ActionGlyph.STOP -> {
Icon(
imageVector = Icons.Default.Stop,
- contentDescription = "Stop listening",
- tint = if (isListening) MaterialTheme.colorScheme.onErrorContainer else palette.onAction,
+ contentDescription =
+ if (canInterrupt) {
+ stringResource(R.string.chat_voice_stop_generating)
+ } else {
+ "Stop listening"
+ },
+ tint =
+ if (isListening && !canInterrupt) {
+ MaterialTheme.colorScheme.onErrorContainer
+ } else {
+ palette.onAction
+ },
)
}
@@ -632,3 +717,91 @@ fun buildReasoningLabel(
}
return reqLabel
}
+
+/**
+ * Telegram-style mic gesture: press and hold past [HOLD_TO_RECORD_THRESHOLD_MS]
+ * records a voice note, release sends it, and sliding away (left or up) from
+ * the button cancels instead of sending. Short taps use the control's normal
+ * click handler for dictation, so touch and accessibility actions share one
+ * callback path.
+ *
+ * The whole down-to-up sequence lives in this one pointer loop, so the release
+ * that submits can never be lost to a recomposition between press and
+ * finger-up — an earlier interaction-source based approach could lose it and
+ * leave the recorder running until some later tap "sent" the stale clip.
+ */
+private suspend fun PointerInputScope.micHoldHandler(
+ isEnabled: () -> Boolean,
+ onHoldStart: () -> Unit,
+ onHoldEnd: () -> Unit,
+ onHoldCancel: () -> Unit,
+) {
+ val cancelSlop = MIC_SLIDE_CANCEL_DP.dp.toPx()
+ awaitEachGesture {
+ val down = awaitFirstDown(requireUnconsumed = false)
+ if (!isEnabled()) {
+ return@awaitEachGesture
+ }
+
+ fun slidAway(position: Offset): Boolean {
+ val dx = position.x - down.position.x
+ val dy = position.y - down.position.y
+ return dx < -cancelSlop || dy < -cancelSlop
+ }
+
+ // Phase 1 — a finger-up before the threshold is a plain tap.
+ var earlyUp = false
+ var gestureAborted = false
+ withTimeoutOrNull(HOLD_TO_RECORD_THRESHOLD_MS) {
+ while (true) {
+ val event = awaitPointerEvent()
+ val change =
+ event.changes.firstOrNull { it.id == down.id }
+ ?: run {
+ gestureAborted = true
+ return@withTimeoutOrNull
+ }
+ if (!change.pressed) {
+ earlyUp = true
+ return@withTimeoutOrNull
+ }
+ if (change.isConsumed || slidAway(change.position)) {
+ gestureAborted = true
+ return@withTimeoutOrNull
+ }
+ }
+ }
+ if (gestureAborted) {
+ return@awaitEachGesture
+ }
+ if (earlyUp) {
+ return@awaitEachGesture
+ }
+
+ // Phase 2 — the threshold passed with the finger down: record until
+ // the finger lifts (send) or slides away (cancel). No timeout here.
+ onHoldStart()
+ var send = false
+ try {
+ while (true) {
+ val event = awaitPointerEvent()
+ val change = event.changes.firstOrNull { it.id == down.id } ?: break
+ if (!change.pressed) {
+ send = true
+ break
+ }
+ if (change.isConsumed || slidAway(change.position)) {
+ break
+ }
+ }
+ } finally {
+ if (send) onHoldEnd() else onHoldCancel()
+ }
+ }
+}
+
+/** A press shorter than this is a tap; longer arms voice-note recording. */
+private const val HOLD_TO_RECORD_THRESHOLD_MS = 400L
+
+/** Sliding this far from the mic button (left or up) cancels the recording. */
+private const val MIC_SLIDE_CANCEL_DP = 64
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt
new file mode 100644
index 00000000..1cca004a
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt
@@ -0,0 +1,128 @@
+package com.m57.hermescontrol.ui.chat.components
+
+import android.os.SystemClock
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.m57.hermescontrol.R
+import kotlinx.coroutines.delay
+
+/**
+ * Telegram-style recording strip shown in place of the input field while a
+ * voice note is being recorded: a slide-to-cancel hint, an elapsed timer, and
+ * a live mic level meter so the press and the captured audio are both visible
+ * even when the mic button itself is under the thumb.
+ */
+@Composable
+internal fun VoiceNoteRecordingPanel(
+ amplitude: State,
+ modifier: Modifier = Modifier,
+) {
+ val palette = composerPalette()
+ var elapsedSeconds by remember { mutableStateOf(0L) }
+ LaunchedEffect(Unit) {
+ val startedAt = SystemClock.elapsedRealtime()
+ while (true) {
+ elapsedSeconds = (SystemClock.elapsedRealtime() - startedAt) / 1000
+ delay(200)
+ }
+ }
+ val levels = remember { mutableStateListOf() }
+ LaunchedEffect(Unit) {
+ snapshotFlow { amplitude.value }.collect { level ->
+ levels.add(level)
+ while (levels.size > VOICE_NOTE_BARS) {
+ levels.removeAt(0)
+ }
+ }
+ }
+ Row(
+ modifier =
+ modifier
+ .heightIn(min = 42.dp)
+ .testTag("voice_note_recording_panel"),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ // Decorative: the adjacent "Slide to cancel" text carries the
+ // instruction, so the arrow must not repeat it (review, PR #1250).
+ contentDescription = null,
+ tint = palette.placeholder,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ text = stringResource(R.string.chat_voice_slide_to_cancel),
+ style = MaterialTheme.typography.labelMedium,
+ color = palette.placeholder,
+ maxLines = 1,
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Box(
+ modifier =
+ Modifier
+ .size(8.dp)
+ .background(MaterialTheme.colorScheme.error, CircleShape),
+ )
+ Text(
+ text = formatElapsed(elapsedSeconds),
+ style = MaterialTheme.typography.labelLarge,
+ color = palette.text,
+ )
+ Row(
+ modifier = Modifier.height(24.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ val filler = VOICE_NOTE_BARS - levels.size
+ repeat(VOICE_NOTE_BARS) { index ->
+ val level = if (index < filler) 0f else levels[index - filler]
+ Box(
+ modifier =
+ Modifier
+ .width(3.dp)
+ .height((4f + 18f * level).dp)
+ .background(palette.placeholder, RoundedCornerShape(2.dp)),
+ )
+ }
+ }
+ }
+}
+
+/** Number of level-meter bars in the recording panel. */
+private const val VOICE_NOTE_BARS = 14
+
+/** Formats elapsed recording time as m:ss. */
+private fun formatElapsed(seconds: Long): String {
+ val minutes = seconds / 60
+ val secs = seconds % 60
+ return "$minutes:${secs.toString().padStart(2, '0')}"
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f6d1261c..5fcd2512 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -4,6 +4,9 @@
Send
Listening…
+ Could not start voice recording
+ Slide to cancel
+ Stop generating
Microphone permission is required for voice input
Speech recognition is not available on this device
Send
diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt
index 97a5a730..e7096b39 100644
--- a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt
@@ -2,8 +2,10 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.HttpUrl
+import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -59,6 +61,7 @@ class ProfileScopeInterceptorTest {
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
val builder = Request.Builder().url(server.url(path))
when (method) {
+ "POST" -> builder.post("{}".toRequestBody("application/json".toMediaType()))
"DELETE" -> builder.delete()
else -> builder.get()
}
@@ -269,4 +272,27 @@ class ProfileScopeInterceptorTest {
assertEquals("work", url.queryParameter("profile"))
assertEquals("/api/status/health", url.encodedPath)
}
+
+ @Test
+ fun transcribeRoute_getsTheActiveNonDefaultProfile() {
+ // Review (PR #1250): /api/audio/transcribe resolves STT through the
+ // request's profile on the backend — the desktop sends this route
+ // `...profileScoped()`. Unscoped, a multi-profile host would
+ // transcribe under the launch profile instead of the user's pick.
+ val client = clientFor("work")
+
+ val url = requestedUrl(client, "api/audio/transcribe", method = "POST")
+
+ assertEquals("work", url.queryParameter("profile"))
+ assertEquals("/api/audio/transcribe", url.encodedPath)
+ }
+
+ @Test
+ fun lookalikeAudioPath_notScoped() {
+ val client = clientFor("work")
+
+ val url = requestedUrl(client, "api/audio-extras")
+
+ assertNull(url.queryParameter("profile"))
+ }
}
diff --git a/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt
new file mode 100644
index 00000000..e1387b9a
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt
@@ -0,0 +1,133 @@
+package com.m57.hermescontrol.data.repository
+
+import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
+import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
+import com.m57.hermescontrol.data.remote.HermesApiService
+import com.m57.hermescontrol.data.remote.NetworkResult
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.test.runTest
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import retrofit2.Response
+import java.io.File
+import java.net.SocketTimeoutException
+
+class VoiceNoteRepositoryTest {
+ private val api = mockk()
+
+ private fun tempVoiceFile(bytes: ByteArray = byteArrayOf(1)): File =
+ File.createTempFile("voice-note-test", ".m4a").apply {
+ writeBytes(bytes)
+ deleteOnExit()
+ }
+
+ @Test
+ fun transcribe_uploadsBase64DataUrlAndReturnsTrimmedTranscript() =
+ runTest {
+ val file = tempVoiceFile(byteArrayOf(1, 2, 3))
+ val bodySlot = slot()
+ coEvery { api.transcribeAudio(capture(bodySlot)) } returns
+ Response.success(
+ AudioTranscriptionResponse(ok = true, transcript = " hello world "),
+ )
+
+ val result = VoiceNoteRepository { _ -> api }.transcribe(file)
+
+ val success = result as NetworkResult.Success
+ assertEquals("hello world", success.data)
+ assertEquals("audio/mp4", bodySlot.captured.mimeType)
+ assertEquals("data:audio/mp4;base64,AQID", bodySlot.captured.dataUrl)
+ }
+
+ @Test
+ fun transcribe_propagatesServerFailureWithoutRetrying() =
+ runTest {
+ val file = tempVoiceFile()
+ coEvery { api.transcribeAudio(any()) } returns
+ Response.error(422, "no speech".toResponseBody("text/plain".toMediaType()))
+
+ val result = VoiceNoteRepository { _ -> api }.transcribe(file)
+
+ assertTrue(result is NetworkResult.Failure)
+ coVerify(exactly = 1) { api.transcribeAudio(any()) }
+ }
+
+ @Test
+ fun transcribe_doesNotRetryThePostAfterASocketTimeout() =
+ runTest {
+ // safeApiCall's default policy retries socket timeouts and 5xx; a
+ // blind re-POST would repeat provider STT work after the request
+ // may already have reached the server (review, PR #1250).
+ val file = tempVoiceFile()
+ coEvery { api.transcribeAudio(any()) } throws SocketTimeoutException("stt timeout")
+
+ val result = VoiceNoteRepository { _ -> api }.transcribe(file)
+
+ assertTrue(result is NetworkResult.Failure)
+ coVerify(exactly = 1) { api.transcribeAudio(any()) }
+ }
+
+ @Test
+ fun transcribe_resolvesTheServiceAtCallTime_soAConnectionSwitchUsesTheNewService() =
+ runTest {
+ val firstApi = mockk()
+ val secondApi = mockk()
+ coEvery { firstApi.transcribeAudio(any()) } returns
+ Response.success(AudioTranscriptionResponse(ok = true, transcript = "old server"))
+ coEvery { secondApi.transcribeAudio(any()) } returns
+ Response.success(AudioTranscriptionResponse(ok = true, transcript = "new server"))
+ var currentApi = firstApi
+ val repository = VoiceNoteRepository { _ -> currentApi }
+
+ val before = repository.transcribe(tempVoiceFile())
+ // Simulate ApiClient.rebuild() after a connection/profile switch:
+ // the next call must use the new service, not the captured one.
+ currentApi = secondApi
+ val after = repository.transcribe(tempVoiceFile())
+
+ assertEquals("old server", (before as NetworkResult.Success).data)
+ assertEquals("new server", (after as NetworkResult.Success).data)
+ coVerify(exactly = 1) { firstApi.transcribeAudio(any()) }
+ coVerify(exactly = 1) { secondApi.transcribeAudio(any()) }
+ }
+
+ @Test
+ fun transcribe_requestsTheDesktopParitySttTimeout() =
+ runTest {
+ val requestedTimeouts = mutableListOf()
+ coEvery { api.transcribeAudio(any()) } returns
+ Response.success(AudioTranscriptionResponse(ok = true, transcript = "ok"))
+
+ VoiceNoteRepository { timeout ->
+ requestedTimeouts += timeout
+ api
+ }.transcribe(tempVoiceFile())
+
+ // A tiny clip must still get the 180s floor, not the shared 30s client.
+ assertEquals(listOf(VoiceNoteRepository.MIN_REQUEST_TIMEOUT_MS), requestedTimeouts)
+ }
+
+ @Test
+ fun transcriptionTimeoutMs_scalesWithPayloadThenClamps() {
+ assertEquals(180_000L, VoiceNoteRepository.transcriptionTimeoutMs("data:audio/mp4;base64,AQID"))
+ assertEquals(200_000L, VoiceNoteRepository.transcriptionTimeoutMs("x".repeat(2_000_000)))
+ assertEquals(600_000L, VoiceNoteRepository.transcriptionTimeoutMs("x".repeat(10_000_000)))
+ }
+
+ @Test
+ fun transcribe_unreadableFileReturnsFailureWithoutUploading() =
+ runTest {
+ val missing = tempVoiceFile().apply { delete() }
+
+ val result = VoiceNoteRepository { _ -> api }.transcribe(missing)
+
+ assertTrue(result is NetworkResult.Failure)
+ coVerify(exactly = 0) { api.transcribeAudio(any()) }
+ }
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt
index 3c0c3b57..f3360b34 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager
+import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.model.ActionResponse
import com.m57.hermescontrol.data.remote.ApiClient
@@ -76,6 +77,14 @@ class ChatUpdateCommandTest {
every { android.util.Log.e(any(), any(), any()) } returns 0
mockkObject(AuthManager)
+ // ChatModelSwitchDelegate.preloadModelOptions() reads pinned models
+ // when the catalog load succeeds; without this stub the spy falls
+ // through to the real AuthManager and throws "not initialized".
+ every { AuthManager.getPinnedModels() } returns emptyList()
+ // The delegate and the shared catalog store both collect this flow;
+ // park them on a never-emitting state so a later real-AuthManager
+ // emission cannot resume a stale Main-dispatched collector.
+ every { AuthManager.dataScopeFlow } returns MutableStateFlow(null)
mockkObject(HermesWsClient)
mockkObject(ApiClient)
mockkObject(HermesDatabase)
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
index f750132f..c2a9f6f5 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt
@@ -8,9 +8,11 @@ import android.net.Uri
import android.util.Log
import androidx.core.content.FileProvider
import com.m57.hermescontrol.data.local.AuthManager
+import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
+import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.PaginationInfo
import com.m57.hermescontrol.data.model.SessionMessage
@@ -30,6 +32,7 @@ import com.m57.hermescontrol.data.ws.ConnectionOperationParser
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.JsonRpcError
+import com.m57.hermescontrol.data.ws.ModelCatalogStore
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.notification.TurnCorrelationTracker
@@ -65,6 +68,7 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
+import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertArrayEquals
@@ -75,6 +79,7 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
+import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class ChatViewModelTest {
@@ -298,6 +303,18 @@ class ChatViewModelTest {
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.getBaseUrl() } returns "http://test.local/"
every { AuthManager.getSelectedProfileId() } returns null
+ // sendVoiceNote snapshots the data scope for its ownership check
+ // (review, PR #1250). Default it to null: an unstubbed mockk call
+ // used to throw and be swallowed by the runCatching callers, which
+ // kept SwrCache keys unscoped in this class; returning a concrete
+ // scope here enables cross-test cache hits and skips sends other
+ // tests count. Scope-specific tests re-stub this with real scopes.
+ every { AuthManager.currentDataScope() } returns null
+ // ChatViewModel's model-switch delegate and the shared catalog store
+ // both collect this flow; park them on a never-emitting state so a
+ // later real-AuthManager emission cannot resume a stale
+ // Main-dispatched collector between test classes.
+ every { AuthManager.dataScopeFlow } returns MutableStateFlow(null)
mockkObject(ProfileSwitchCoordinator)
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow()
@@ -1098,6 +1115,13 @@ class ChatViewModelTest {
runTest {
val (viewModel, _) = createViewModelWithSession()
+ // The shared catalog store may still hold another class's cached
+ // (possibly empty) state — reset it so the picker's load below
+ // fetches through this class's stubbed API.
+ ModelCatalogStore.shared.onScopeChanged(
+ DataScope("preload-reset", "http://preload-reset.test/", AuthManager.DEFAULT_PROFILE_ID),
+ )
+
// A bare "/model" must NOT dispatch a slash command; it opens the picker.
viewModel.sendMessage("/model")
advanceUntilIdle()
@@ -9746,4 +9770,414 @@ class ChatViewModelTest {
}
assertNull(TurnCorrelationTracker.boundaryFor("default", sessionId))
}
+
+ @Test
+ fun sendVoiceNote_transcribesViaServerAndSubmitsTranscript() =
+ runTest {
+ TurnCorrelationTracker.resetForTest()
+ stubActiveProfile()
+ val api = mockk(relaxed = true)
+ every { ApiClient.hermesApi } returns api
+ every { ApiClient.transcriptionService(any()) } returns api
+ coEvery { api.transcribeAudio(any()) } returns
+ Response.success(
+ AudioTranscriptionResponse(ok = true, transcript = "hello from voice"),
+ )
+ val (viewModel, _) = createViewModelWithSession()
+
+ val submittedTexts = mutableListOf()
+ every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
+ reqCount++
+ val id = "req-msg-$reqCount"
+ submittedTexts += arg(1)
+ arg<((String) -> Unit)?>(2)?.invoke(id)
+ id
+ }
+
+ val voiceFile =
+ java.io.File(attachmentCacheDir, "voice_note.m4a").apply {
+ writeBytes(byteArrayOf(1, 2, 3))
+ }
+ viewModel.sendVoiceNote(voiceFile)
+ advanceUntilIdle()
+
+ assertEquals("hello from voice", submittedTexts.lastOrNull())
+ assertFalse(voiceFile.exists())
+ assertNull(viewModel.uiState.value.errorMessage)
+ assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
+ }
+
+ @Test
+ fun sendVoiceNote_reportsTranscriptionFailureAndDeletesClip() =
+ runTest {
+ val api = mockk(relaxed = true)
+ every { ApiClient.hermesApi } returns api
+ every { ApiClient.transcriptionService(any()) } returns api
+ coEvery { api.transcribeAudio(any()) } returns
+ Response.error(500, "transcribe failed".toResponseBody("text/plain".toMediaType()))
+ val (viewModel, _) = createViewModelWithSession()
+
+ val voiceFile =
+ java.io.File(attachmentCacheDir, "voice_fail.m4a").apply {
+ writeBytes(byteArrayOf(9))
+ }
+ viewModel.sendVoiceNote(voiceFile)
+ advanceUntilIdle()
+
+ assertTrue(
+ viewModel.uiState.value.errorMessage
+ ?.contains("Voice note transcription failed") == true,
+ )
+ assertFalse(voiceFile.exists())
+ assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
+ }
+
+ @Test
+ fun sendVoiceNote_emptyTranscriptShowsNoSpeechAndDoesNotSubmit() =
+ runTest {
+ val api = mockk(relaxed = true)
+ every { ApiClient.hermesApi } returns api
+ every { ApiClient.transcriptionService(any()) } returns api
+ coEvery { api.transcribeAudio(any()) } returns
+ Response.success(AudioTranscriptionResponse(ok = true, transcript = " "))
+ val (viewModel, _) = createViewModelWithSession()
+
+ val voiceFile =
+ java.io.File(attachmentCacheDir, "voice_silent.m4a").apply {
+ writeBytes(byteArrayOf(0))
+ }
+ viewModel.sendVoiceNote(voiceFile)
+ advanceUntilIdle()
+
+ assertTrue(
+ viewModel.uiState.value.errorMessage
+ ?.contains("No speech detected") == true,
+ )
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun sendVoiceNote_whileDisconnectedDropsClipLocallyWithoutUploading() =
+ runTest {
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ val voiceFile =
+ java.io.File(attachmentCacheDir, "voice_offline.m4a").apply {
+ writeBytes(byteArrayOf(1))
+ }
+ viewModel.sendVoiceNote(voiceFile)
+ advanceUntilIdle()
+
+ assertFalse(voiceFile.exists())
+ assertTrue(
+ viewModel.uiState.value.errorMessage
+ ?.contains("not connected") == true,
+ )
+ }
+
+ @Test
+ fun sendVoiceNote_whenSessionChangesMidTranscription_keepsTheTranscriptInTheComposer() =
+ runTest {
+ stubActiveProfile()
+ val api = mockk(relaxed = true)
+ every { ApiClient.hermesApi } returns api
+ every { ApiClient.transcriptionService(any()) } returns api
+ val transcription = CompletableDeferred>()
+ coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() }
+ val (viewModel, _) = createViewModelWithSession()
+
+ val voiceFile =
+ java.io.File(attachmentCacheDir, "voice_session_race.m4a").apply {
+ writeBytes(byteArrayOf(7))
+ }
+ viewModel.sendVoiceNote(voiceFile)
+ advanceUntilIdle()
+
+ // The user moves to a new session while STT is still running.
+ viewModel.createNewSession()
+ advanceUntilIdle()
+
+ transcription.complete(
+ Response.success(AudioTranscriptionResponse(ok = true, transcript = "late transcript")),
+ )
+ advanceUntilIdle()
+
+ // The recording belonged to the old session: the transcript must
+ // survive in the composer instead of landing in a session it was
+ // never recorded for, or vanishing (review, PR #1250).
+ assertEquals("late transcript", viewModel.uiState.value.composerTextToRestore)
+ assertTrue(
+ viewModel.uiState.value.errorMessage
+ ?.contains("transcript kept") == true,
+ )
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
+ assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
+ assertFalse(voiceFile.exists())
+ }
+
+ @Test
+ fun sendVoiceNote_whenTheSendIsRejected_keepsTheTranscriptInTheComposer() =
+ runTest {
+ stubActiveProfile()
+ val api = mockk(relaxed = true)
+ every { ApiClient.hermesApi } returns api
+ every { ApiClient.transcriptionService(any()) } returns api
+ coEvery { api.transcribeAudio(any()) } returns
+ Response.success(AudioTranscriptionResponse(ok = true, transcript = "keep me"))
+ val (viewModel, _) = createViewModelWithSession()
+
+ val voiceFile =
+ java.io.File(attachmentCacheDir, "voice_send_race.m4a").apply {
+ writeBytes(byteArrayOf(8))
+ }
+ viewModel.sendVoiceNote(voiceFile)
+ // The connection drops while the transcript is being produced.
+ mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
+ advanceUntilIdle()
+
+ assertEquals("keep me", viewModel.uiState.value.composerTextToRestore)
+ assertTrue(
+ viewModel.uiState.value.errorMessage
+ ?.contains("transcript kept") == true,
+ )
+ verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun sendVoiceNote_whileATranscriptionIsInFlight_dropsTheSecondClip() =
+ runTest {
+ stubActiveProfile()
+ val api = mockk(relaxed = true)
+ every { ApiClient.hermesApi } returns api
+ every { ApiClient.transcriptionService(any()) } returns api
+ val transcription = CompletableDeferred>()
+ coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() }
+ val (viewModel, _) = createViewModelWithSession()
+
+ val submittedTexts = mutableListOf()
+ every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
+ reqCount++
+ val id = "req-msg-$reqCount"
+ submittedTexts += arg(1)
+ arg<((String) -> Unit)?>(2)?.invoke(id)
+ id
+ }
+
+ val firstFile =
+ java.io.File(attachmentCacheDir, "voice_first.m4a").apply { writeBytes(byteArrayOf(1)) }
+ val secondFile =
+ java.io.File(attachmentCacheDir, "voice_second.m4a").apply { writeBytes(byteArrayOf(2)) }
+ viewModel.sendVoiceNote(firstFile)
+ advanceUntilIdle()
+
+ viewModel.sendVoiceNote(secondFile)
+ advanceUntilIdle()
+
+ // Single-flight: the second clip is dropped without a second upload.
+ assertFalse(secondFile.exists())
+ coVerify(exactly = 1) { api.transcribeAudio(any()) }
+
+ transcription.complete(
+ Response.success(AudioTranscriptionResponse(ok = true, transcript = "only once")),
+ )
+ advanceUntilIdle()
+
+ assertEquals(listOf("only once"), submittedTexts)
+ assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
+ }
+
+ @Test
+ fun sendVoiceNote_whenDataScopeChangesDuringTranscription_doesNotSendIntoNewScope() =
+ runTest {
+ stubActiveProfile()
+ val api = mockk(relaxed = true)
+ every { ApiClient.hermesApi } returns api
+ every { ApiClient.transcriptionService(any()) } returns api
+ val transcription = CompletableDeferred>()
+ coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() }
+
+ every { AuthManager.currentDataScope() } returns
+ DataScope(
+ connectionProfileId = "alpha",
+ baseUrl = "http://alpha.test/",
+ activeProfileId = AuthManager.DEFAULT_PROFILE_ID,
+ )
+
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ mockConnectionStatus.value = ConnectionStatus.CONNECTED
+ // A pending SESSION_CREATE is all this test needs; skipping the
+ // gateway preload keeps the shared ModelCatalogStore out of it.
+ viewModel.createNewSession()
+ advanceUntilIdle()
+
+ // SESSION_CREATE is still pending, so the storage session ID is
+ // null — exactly the window where the session-ID snapshot alone
+ // cannot detect a scope move.
+ assertNull(viewModel.uiState.value.currentSessionId)
+
+ val voiceFile =
+ java.io.File(attachmentCacheDir, "voice_scope_switch.m4a").apply {
+ writeBytes(byteArrayOf(1, 2, 3))
+ }
+ viewModel.sendVoiceNote(voiceFile)
+ advanceUntilIdle()
+ assertTrue(viewModel.uiState.value.isTranscribingVoiceNote)
+
+ // The user switches to another server/profile while STT runs; the
+ // replacement session create is pending as well, so both session
+ // IDs can stay null/null and only the data scope moves.
+ every { AuthManager.currentDataScope() } returns
+ DataScope(
+ connectionProfileId = "beta",
+ baseUrl = "http://beta.test/",
+ activeProfileId = AuthManager.DEFAULT_PROFILE_ID,
+ )
+ advanceUntilIdle()
+
+ transcription.complete(
+ Response.success(
+ AudioTranscriptionResponse(ok = true, transcript = "do not send this to the new server"),
+ ),
+ )
+ advanceUntilIdle()
+
+ // The transcript must not become the initial prompt of the new
+ // scope; it stays in the composer instead (review, PR #1250).
+ verify(exactly = 0) {
+ HermesWsClient.sendMessage(any(), "do not send this to the new server", any(), any())
+ }
+ assertEquals(
+ "do not send this to the new server",
+ viewModel.uiState.value.composerTextToRestore,
+ )
+ assertTrue(
+ viewModel.uiState.value.errorMessage
+ ?.contains("transcript kept") == true,
+ )
+ assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
+ assertFalse(voiceFile.exists())
+ }
+
+ @Test
+ fun sendVoiceNote_whenServerProfileChangesDuringTranscription_doesNotSendIntoNewScope() =
+ runTest {
+ stubActiveProfile()
+ val api = mockk(relaxed = true)
+ every { ApiClient.hermesApi } returns api
+ every { ApiClient.transcriptionService(any()) } returns api
+ val transcription = CompletableDeferred>()
+ coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() }
+
+ every { AuthManager.currentDataScope() } returns
+ DataScope(
+ connectionProfileId = "alpha",
+ baseUrl = "http://alpha.test/",
+ activeProfileId = "default",
+ )
+
+ val viewModel = createViewModel()
+ advanceUntilIdle()
+
+ mockConnectionStatus.value = ConnectionStatus.CONNECTED
+ // A pending SESSION_CREATE is all this test needs; skipping the
+ // gateway preload keeps the shared ModelCatalogStore out of it.
+ viewModel.createNewSession()
+ advanceUntilIdle()
+
+ val voiceFile =
+ java.io.File(attachmentCacheDir, "voice_profile_switch.m4a").apply {
+ writeBytes(byteArrayOf(4, 5, 6))
+ }
+ viewModel.sendVoiceNote(voiceFile)
+ advanceUntilIdle()
+ assertTrue(viewModel.uiState.value.isTranscribingVoiceNote)
+
+ // Same server, different server-side Hermes profile: the session
+ // IDs cannot see this change either, so the scope must.
+ every { AuthManager.currentDataScope() } returns
+ DataScope(
+ connectionProfileId = "alpha",
+ baseUrl = "http://alpha.test/",
+ activeProfileId = "work",
+ )
+ advanceUntilIdle()
+
+ transcription.complete(
+ Response.success(
+ AudioTranscriptionResponse(ok = true, transcript = "profile-scoped transcript"),
+ ),
+ )
+ advanceUntilIdle()
+
+ verify(exactly = 0) {
+ HermesWsClient.sendMessage(any(), "profile-scoped transcript", any(), any())
+ }
+ assertEquals(
+ "profile-scoped transcript",
+ viewModel.uiState.value.composerTextToRestore,
+ )
+ assertTrue(
+ viewModel.uiState.value.errorMessage
+ ?.contains("transcript kept") == true,
+ )
+ assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
+ assertFalse(voiceFile.exists())
+ }
+
+ @Test
+ fun canInterrupt_staysFalseWhileTypingWithNoRuntimeSession_thenTracksTheGeneration() =
+ runTest {
+ stubActiveProfile()
+ val vm = createViewModel()
+ advanceUntilIdle()
+
+ mockConnectionStatus.value = ConnectionStatus.CONNECTED
+ mockEventsFlow.emit(WsEvent.GatewayReady(null))
+ advanceUntilIdle()
+
+ val sentPrompts = mutableListOf()
+ every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
+ reqCount++
+ val id = "req-msg-$reqCount"
+ sentPrompts += arg(1)
+ arg<((String) -> Unit)?>(2)?.invoke(id)
+ id
+ }
+
+ // Queued while session create is in flight: sending is pending,
+ // but the session has no runtime to interrupt yet, so Stop must stay
+ // hidden and interruptSession() must not be reachable from it
+ // (review, PR #1250).
+ vm.sendMessage("held under session preparation")
+ advanceUntilIdle()
+ assertTrue(vm.uiState.value.isSending)
+ assertFalse(vm.uiState.value.canInterrupt)
+
+ // Once the runtime session exists and the prompt is dispatched,
+ // the running generation is interruptible. Read the captured
+ // session.create id instead of counting sends: a singleton store
+ // warmed by an earlier test can fire an extra model.options send
+ // in this gateway flow and shift the counter (see the resume
+ // tests, same pattern).
+ val createReqId =
+ sentRequestMethods.last { it.first == WsMethods.SESSION_CREATE }.second
+ mockEventsFlow.emit(
+ WsEvent.RpcResult(
+ createReqId,
+ mapOf("session_id" to "session-969", "stored_session_id" to "session-storage-969"),
+ ),
+ )
+ advanceUntilIdle()
+
+ assertEquals(listOf("held under session preparation"), sentPrompts)
+ // A successful prompt.submit acknowledgement only means the
+ // gateway accepted it; the generation is interruptible only once
+ // the runtime reports message.start for its session.
+ mockEventsFlow.emit(WsEvent.MessageStart("session-969"))
+ advanceUntilIdle()
+ assertTrue(vm.uiState.value.canInterrupt)
+ }
}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt
index 60cb31a2..1447a65e 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager
+import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.model.PaginationInfo
import com.m57.hermescontrol.data.model.SessionMessagesResponse
@@ -77,6 +78,14 @@ class SlashCommandDispatchRpcTest {
// COMMAND_DISPATCH captures in suite order). setMain alone is safe —
// the slash-dispatch send path is synchronous (no IO hop).
mockkObject(AuthManager)
+ // ChatModelSwitchDelegate.preloadModelOptions() reads pinned models
+ // when the catalog load succeeds; without this stub the spy falls
+ // through to the real AuthManager and throws "not initialized".
+ every { AuthManager.getPinnedModels() } returns emptyList()
+ // The delegate and the shared catalog store both collect this flow;
+ // park them on a never-emitting state so a later real-AuthManager
+ // emission cannot resume a stale Main-dispatched collector.
+ every { AuthManager.dataScopeFlow } returns MutableStateFlow(null)
mockkObject(HermesWsClient)
mockkObject(ApiClient)
mockkObject(HermesDatabase)
@@ -459,7 +468,11 @@ class SlashCommandDispatchRpcTest {
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
- val m = methodCalls.last()
+ // Read the method from THIS invocation, not from the shared
+ // capture list: a concurrent request() landing between capture
+ // and answer can leave .last() pointing at another call (CI:
+ // "Value not yet captured" for the submitted prompt).
+ val m = arg(0)
val d = CompletableDeferred()
if (m == WsMethods.COMMAND_DISPATCH) {
// Backend rejects /status with the registry-miss 4018 (issue #576).
@@ -729,7 +742,9 @@ class SlashCommandDispatchRpcTest {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
val d = CompletableDeferred()
- if (methodCalls.last() == WsMethods.COMMAND_DISPATCH) {
+ // Read the method from THIS invocation, not from the shared
+ // capture list (see the 4018 test above).
+ if (arg(0) == WsMethods.COMMAND_DISPATCH) {
// Backend /init returns type:"send" with the AGENTS.md prompt.
d.complete(mapOf("type" to "send", "message" to "Scan this repo and write AGENTS.md"))
} else {
From 8f40f028e03b271939703626459c5ce406f39895 Mon Sep 17 00:00:00 2001
From: marciodps <95321123+marciodps@users.noreply.github.com>
Date: Fri, 25 Sep 2026 19:20:32 +0100
Subject: [PATCH 012/107] fix(ws): suppress deferred-API RPC error events for
opt-in callers (#1265)
Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
---
.../m57/hermescontrol/data/ws/HermesWsClient.kt | 14 +++++++++++++-
.../hermescontrol/data/ws/SubagentRepository.kt | 9 ++++++++-
2 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt
index ab3fa379..1098e7be 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt
@@ -775,6 +775,7 @@ object HermesWsClient {
val method: String,
val deferred: CompletableDeferred,
var timeoutJob: Job? = null,
+ val suppressErrorEvent: Boolean = false,
)
/** Tracks in-flight [request] calls by their JSON-RPC id. */
@@ -794,11 +795,12 @@ object HermesWsClient {
method: String,
params: Map = emptyMap(),
timeoutMs: Long = REQUEST_TIMEOUT_MS,
+ suppressErrorEvent: Boolean = false,
): CompletableDeferred {
val deferred = CompletableDeferred()
val id =
send(method, params) { reqId ->
- pendingCalls[reqId] = PendingCall(method, deferred)
+ pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent)
}
deferred.invokeOnCompletion { cause ->
if (cause is CancellationException) {
@@ -1458,6 +1460,16 @@ object HermesWsClient {
disconnectIfIdleInBackground()
}
}
+ if (pendingCalls[event.id]?.suppressErrorEvent == true) {
+ // Opt-in suppression: the caller already handles the
+ // failure through the CompletableDeferred, so the event
+ // copy would only surface a duplicate UI banner for
+ // optional features (e.g. "subagent.list" on gateways
+ // without the method, issue #1089). Default request()
+ // behavior still emits the event for shared consumers.
+ resolvePending(event.id, null, event.error)
+ return
+ }
removeQueuedMessage(event.id)
resolvePending(event.id, null, event.error)
}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt
index 45a9ac37..1cda13ec 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt
@@ -37,6 +37,7 @@ object SubagentRepository {
.request(
WsMethods.SUBAGENT_LIST,
mapOf("session_id" to sessionId),
+ suppressErrorEvent = true,
).await()
decode(result)
} catch (e: Exception) {
@@ -65,7 +66,13 @@ object SubagentRepository {
if (sessionId.isNotBlank()) {
params["session_id"] = sessionId
}
- val result = HermesWsClient.request(WsMethods.SUBAGENT_TAIL, params).await()
+ val result =
+ HermesWsClient
+ .request(
+ WsMethods.SUBAGENT_TAIL,
+ params,
+ suppressErrorEvent = true,
+ ).await()
return decode(result)
}
From 2938f607f00a6a1b8d04738270f39bfa1f800a4b Mon Sep 17 00:00:00 2001
From: marciodps <95321123+marciodps@users.noreply.github.com>
Date: Fri, 25 Sep 2026 19:44:26 +0100
Subject: [PATCH 013/107] fix(build): sign debug builds with a committed stable
keystore (#1266)
Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
---
app/build.gradle.kts | 12 +++++++++++-
app/debug.keystore | Bin 0 -> 2666 bytes
2 files changed, 11 insertions(+), 1 deletion(-)
create mode 100644 app/debug.keystore
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 63cd14e3..df81b98c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -36,6 +36,16 @@ android {
}
signingConfigs {
+ getByName("debug") {
+ // Stable debug keystore committed to the repo so debug APKs keep
+ // the same signature across machines and CI runs. CI runners use
+ // an ephemeral default debug keystore otherwise, which makes every
+ // CI debug APK uninstallable over a previously installed build.
+ storeFile = file("debug.keystore")
+ storePassword = "android"
+ keyAlias = "androiddebugkey"
+ keyPassword = "android"
+ }
create("release") {
val isReleaseBuild =
gradle.startParameter.taskNames.any { name ->
@@ -301,4 +311,4 @@ tasks.named("check") {
tasks.withType {
useJUnitPlatform()
-}
\ No newline at end of file
+}
diff --git a/app/debug.keystore b/app/debug.keystore
new file mode 100644
index 0000000000000000000000000000000000000000..882c29b6e6a4fe6eb3568ace5bffbee6dacd024a
GIT binary patch
literal 2666
zcma)8X*kr4`kpb1##ly#n3u7X5Po)%Wu%NUA(X9TH)G4Ol?)lO4B5hqtYwLkeJxu?
zWf?{XBTEQ*sc8`gW9s-{|8w-i4}Ru3h^Y7XeCgDQt3|Lx-C0dtj*Va5mKX~65Z1I7n9
z0J?vVynqr^*lKo9)qSjQlud${QM?un(t1;gTwL-fE-(h*g&+F=i(Cj82>1hzxE_xK
zdqSXKMJVe0YGTiTi7mH1e@NyvDeoO98A?|W1?WfzwbKSmT9!^YSq40=zIk-#&FPnL
zQfwWqhu_OD%9bZi9<1kSY?-fjk+)rK=p)KidsCMe@i=x*`sl65DDD3AMuHb<_4ce4
zRj5NhgA&r2Uy-Y2(m>?K+&;JCU>#5*yO#HJgE8Z$Eptq$I@0b{{&wL~?o_IIMFudM
zpsr0clbR@o#8BoB1M=2jy
z86ibGhrl(Ty=dF`5hs)FIHBCRrvUltv0L#;Ue3S$Vz^(T!Wa9p}{YC3HAfmCxxL;i$1~W02
zOTjI#k#KZ}`t_KaLi=6v`}Y_IDIa&2zx=MmP+_I!rnI|NrCw+4hhH={b?5SViX~%E
zXct??=0BOflwG6@UaYfDMOFZ3*Og{Rn+I7E`2h8TD&KzrH#X9MWm3K{p%NBpZlo%}g)hOTK
zyZ*brq{Votp86oui}z9&oo;j$jcJNN24wBD9cu_i8g5sNBnfW{k2T~?+00=LTj(|-
z`Nn$vf(3PC738F`c)fW^Z8C#WK~;#_tUX*pj%-c~$;7)fa~q9RsNC>*Dqf;uVLSg7
zbxl^>frHLfI>xh|?=xAvRGyKWz!u*%%}>;9F9eA)HOz8*r<(>UWA|l(Ux8OxhX`C{
z?wIn9v;?TuhaR%y)zX_+TjsK3GspN5{j!Q&64vyHP{_KNOD8d+=+S;kHr@o)Y^rV+
zQ{o&VE?kYKU%I9*D?3A|Khmo5puV`Lkct~{!r{f%EfX5x40-s=f*dwH^kYb*
z_SfC4M(_rFA%8&0`*Gh=c<9wUE9sv4v)A0`DnKm2%Z{3@)CpSpkh9&+rF
zW3ha$chw9y^3mpAz>VaI;vmD>?6Qt<8T{v+4NQD9<;B#VVEM!aIrxP`$D{buPE~02
z+0|{%(}pXepFTMr|5QcX)WH{WWPIlvnl2_KaEM!==I9FrkN$!Fg6r?f8n#3@5e8K(
z_T@t+3s`}^Nx;{_xC|ksBB3@_)U_==W8^T&*Q8~#8niIzl!@-VnIM}kxl$$hWVWa`
zT0W;&{L*+g?Dl&t4?k*>ebP+IiSmW&EBYfvL>&XJ&lI~A+W_a$5R(U2ZJ9OlK}-i?
z9{s1wpz8RywMwKi@}Ym&1GzVy8
zt>tP0*{|fU^Eqc==`Yk@Pqj^6g$iKYq91*lSF#2-mwwq9bJCwOdCzo7ql@rH@^ao@
z(>~8iH>Gt>iZ=iHxv
zaOSFwlk;w>T>_MU$0b5o4I?QDas>r|JV5^d1%e0%=mGKsxq(7K-XPzD?iL{Z_xcev
zVd!C7g1;w5P3IJ#p``<8XahPr02!+Gdx;xfLWas5P?B6=&;f}4t$_bqaO2`C{F|HF
zS#7}v*K1M2vNOv^S^rCLQ(wOwHDt(Jk=6U&{B^d4`wkgWqT#Y@8=2Sl%!Q_564e
zz0n=%oJClak}(VEL8DlC_hHepTC+?)yf4#jY6Y2AD`xCzt9UI#x3zlh-Qd}dL|jF1
zicLhWX>Dq&Y@@*FB<*mz=3G3l!9u+z&6}x+3an#fO?tMQ6gIKo#|KwQhRsuPNkh((5mKc%Zsz+Y`?ec;LfW=
zndad>yM5!WL|58SRG1bR5>8#pICT@de!
z;QpHE4NaaulOxpdju(P+D{P~LeYt~D>rKxJYP*CUh}lA4C*#MfGdcfkyj(3pzyuZ(
zcq{|+UMnH&=7*hPxy$=+&fdYozLt^;mHoBDbt1!U+0xm*dZH5gHm9oGE^&P9{fes3
zq{i^CglMeq@Cuh8=*~=-H2+81bcubrvgjayhpk9l3>W9z8bSTFRq`!JeM9njm(Q@0
z%dwSGYPnNnk~+)J;@55R@JPS&xw)}jnO8!e6aJhR3ULgh6i7^#WUUcrwau@%42>-|
zt!tJm*?ZN5+)TO>eePBb!KW-G(6CzalGS2XO_9O_k02wHNSGQ#s{ZafG?L|S^Fd@%
z@2VoYo!4*)qS`uCYEF_$d01=-A+O(Cla^G{XdjxBjt=~D8SPfwx2$`vJi@{ogmDgP
z>h*1Yv`>h$qg_zSwHan&Vl9XT-GKuh1JEE(h|^*vZIr@&nr$uWU=#)jY1O>LP8^Qd
znzMVRdlB#GkaR9%HR-cnH4D8ksh0sbw`HG{HwyDA8drLpu^RJwhC+l08=en26B==P
z_1ADX2eO1498zxuT?~5I61X$
yBA@_DB8o&R45%Dj3SiXP&
literal 0
HcmV?d00001
From 719a89b95101ddd8acff6017393ef9abf3602ccf Mon Sep 17 00:00:00 2001
From: marciodps <95321123+marciodps@users.noreply.github.com>
Date: Fri, 25 Sep 2026 19:55:49 +0100
Subject: [PATCH 014/107] fix(chat): restore pending clarify bubble from
resume/info replay (#1264)
Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
---
.../hermescontrol/ui/chat/ChatViewModel.kt | 70 +++++++++++++++++++
1 file changed, 70 insertions(+)
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
index 12832256..a46daaab 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt
@@ -30,6 +30,7 @@ import com.m57.hermescontrol.data.ws.CommandBlocklist
import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.data.ws.ConnectionOperationParser
import com.m57.hermescontrol.data.ws.ConnectionStatus
+import com.m57.hermescontrol.data.ws.EventParser
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.WsEvent
@@ -1532,9 +1533,67 @@ class ChatViewModel(
runtimeSessionId ?: _uiState.value.currentSessionId,
)
}
+ // Session.info can carry "pending_clarify" the same way — restore
+ // the clarify bubble so a question asked while detached stays
+ // answerable after leaving and re-entering the chat.
+ val pendingClarify = info["pending_clarify"] as? Map<*, *>
+ if (pendingClarify != null) {
+ surfacePendingClarify(
+ pendingClarify,
+ runtimeSessionId ?: _uiState.value.currentSessionId,
+ )
+ }
}
}
+ /**
+ * Restore a pending clarify from the "pending_clarify" replay payload that
+ * "session.resume" / "session.info" carry while a clarify blocks the turn
+ * server-side (gateway _live_session_payload). Without this, leaving and
+ * re-entering a chat shows the "waiting for input" status with no way to
+ * answer: the live "clarify.request" event only reached whatever client
+ * was attached when the agent asked.
+ *
+ * The payload has the same shape as a live "clarify.request" event (batch
+ * "questions" or legacy "question"/"choices", plus the "request_id" the
+ * answer must reference), so reuse [EventParser] and feed the typed event
+ * through [handleWsEvent] — the exact path a live question takes. Batch
+ * replays may also carry locked per-question "answers".
+ */
+ private fun surfacePendingClarify(
+ payload: Map<*, *>,
+ sessionId: String?,
+ ) {
+ val clarifyId = (payload["clarify_id"] ?: payload["request_id"]) as? String
+ if (clarifyId != null && _uiState.value.clarifyRequest?.clarifyId == clarifyId) {
+ // Already on screen — avoid clobbering in-progress answer state.
+ return
+ }
+ val clarifyPayload: Map = payload.entries.associate { (k, v) -> k.toString() to v }
+ val event =
+ EventParser.parseParams(
+ mapOf(
+ "type" to "clarify.request",
+ "session_id" to sessionId,
+ "payload" to clarifyPayload,
+ ),
+ ) as? WsEvent.ClarifyRequest ?: return
+ if (event.questions.isEmpty() && event.text.isNullOrBlank() && event.options.isNullOrEmpty()) {
+ return
+ }
+ // "answers" is replay-only (locked batch answers); mirror it into the
+ // event so restored questions render their answered state.
+ val lockedAnswers =
+ (clarifyPayload["answers"] as? Map<*, *>)
+ ?.mapNotNull { (qid, answer) ->
+ val id = qid as? String ?: return@mapNotNull null
+ val text = answer as? String ?: return@mapNotNull null
+ id to text
+ }?.toMap()
+ ?: emptyMap()
+ handleWsEvent(event.copy(lockedAnswers = lockedAnswers))
+ }
+
// ── RPC response handling ────────────────────────────────────────────
private fun settleOutgoingResult(
@@ -1821,6 +1880,17 @@ class ChatViewModel(
runtimeSessionId ?: sessionId,
)
}
+ // Reconnect replay: resume payload can carry "pending_clarify"
+ // (server _live_session_payload) — restore the clarify bubble
+ // so questions asked while the client was detached remain
+ // answerable after leaving and re-entering the session.
+ val pendingClarify = resultMap["pending_clarify"] as? Map<*, *>
+ if (pendingClarify != null) {
+ surfacePendingClarify(
+ pendingClarify,
+ runtimeSessionId ?: sessionId,
+ )
+ }
val activeSessionId = runtimeSessionId ?: sessionId
// Reconnect replay: the backend-owned connector operation is
// authoritative and uses the same full snapshot as the live
From 83ac8a3418ffc660f41ab4cf760ec8ee05cc53a7 Mon Sep 17 00:00:00 2001
From: m57 <41645758+Hy4ri@users.noreply.github.com>
Date: Fri, 25 Sep 2026 22:47:00 +0300
Subject: [PATCH 015/107] fix(#1263): render completed reasoning as Markdown
(#1269)
---
.../ui/chat/components/ReasoningCardTest.kt | 24 +++++++++
.../ui/chat/components/MessageCards.kt | 53 +++++++++++--------
2 files changed, 56 insertions(+), 21 deletions(-)
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReasoningCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReasoningCardTest.kt
index 494509c1..94882e6e 100644
--- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReasoningCardTest.kt
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReasoningCardTest.kt
@@ -71,4 +71,28 @@ class ReasoningCardTest {
composeTestRule.onNodeWithTag("reasoning_card").performClick()
composeTestRule.onNodeWithText("Show full").assertDoesNotExist()
}
+
+ @Test
+ fun completedReasoning_rendersMarkdown() {
+ renderInLazyColumn("**Important** completed thought")
+
+ composeTestRule.onNodeWithTag("reasoning_card").performClick()
+ composeTestRule.onNodeWithText("Important completed thought").assertIsDisplayed()
+ composeTestRule.onNodeWithText("**Important** completed thought").assertDoesNotExist()
+ }
+
+ @Test
+ fun streamingReasoning_remainsRawText() {
+ composeTestRule.setContent {
+ LazyColumn {
+ item(key = "reasoning") {
+ ReasoningCard(reasoningText = "**Thinking**", isStreaming = true)
+ }
+ }
+ }
+
+ composeTestRule.onNodeWithTag("reasoning_card").performClick()
+ composeTestRule.onNodeWithText("**Thinking**").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Thinking").assertDoesNotExist()
+ }
}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt
index 6bd00aaa..df4038d9 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt
@@ -89,6 +89,7 @@ import com.m57.hermescontrol.theme.CodeTerminalBorder
import com.m57.hermescontrol.theme.CodeTerminalMuted
import com.m57.hermescontrol.theme.CodeTerminalText
import com.m57.hermescontrol.ui.chat.ClarifyUi
+import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.chat.SubagentIndicator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -101,7 +102,8 @@ import kotlinx.coroutines.withContext
* (reasoning-model thinking steps) before the final answer.
*
* Collapsed: "🧠 Reasoning · {N} steps" with chevron.
- * Expanded: reasoning text in monospace bodySmall, capped at ~40% of the
+ * Expanded: completed reasoning is Markdown; streaming text stays raw,
+ * capped at ~40% of the
* screen height with internal scroll; "Show full" lifts the cap.
* Long-press anywhere: copies the reasoning trace to the clipboard.
* Streaming: pulsing indicator at the bottom while [isStreaming].
@@ -180,26 +182,35 @@ fun ReasoningCard(
}
AnimatedVisibility(visible = expanded) {
Column {
- Text(
- text = reasoningText,
- style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier =
- Modifier
- .padding(top = 4.dp)
- .then(
- if (!fullHeight) {
- // Capped at ~40% of the screen with internal scroll.
- Modifier.heightIn(max = capHeight).verticalScroll(scrollState)
- } else {
- // "Show full": render the whole trace at natural height.
- // A scrollable must NEVER be measured with an infinite
- // max height — inside a LazyColumn item (unbounded main
- // axis) that combination throws IllegalStateException.
- Modifier
- },
- ),
- )
+ val contentModifier =
+ Modifier
+ .padding(top = 4.dp)
+ .then(
+ if (!fullHeight) {
+ // Capped at ~40% of the screen with internal scroll.
+ Modifier.heightIn(max = capHeight).verticalScroll(scrollState)
+ } else {
+ // "Show full": render the whole trace at natural height.
+ // A scrollable must NEVER be measured with an infinite
+ // max height — inside a LazyColumn item (unbounded main
+ // axis) that combination throws IllegalStateException.
+ Modifier
+ },
+ )
+ if (isStreaming) {
+ Text(
+ text = reasoningText,
+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = contentModifier,
+ )
+ } else {
+ MarkdownText(
+ text = reasoningText,
+ textColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = contentModifier,
+ )
+ }
if (isStreaming) {
ReasoningPulsingDot(modifier = Modifier.padding(top = 6.dp))
}
From e80b00a6a73eb112d420ed280c99e1830342df3c Mon Sep 17 00:00:00 2001
From: m57 <41645758+Hy4ri@users.noreply.github.com>
Date: Fri, 25 Sep 2026 22:48:50 +0300
Subject: [PATCH 016/107] fix(#1263): render Markdown inside table cells
(#1267)
---
.../ui/chat/MarkdownTableRenderingTest.kt | 35 ++++++++++++++
.../m57/hermescontrol/ui/chat/MarkdownText.kt | 10 +++-
.../ui/chat/markdown/MarkdownTableView.kt | 48 ++++++++++++-------
3 files changed, 74 insertions(+), 19 deletions(-)
create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt
new file mode 100644
index 00000000..559a5fc8
--- /dev/null
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt
@@ -0,0 +1,35 @@
+package com.m57.hermescontrol.ui.chat
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class MarkdownTableRenderingTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun tableHeaderAndCellRenderInlineMarkup() {
+ composeTestRule.setContent {
+ MaterialTheme {
+ MarkdownText(
+ text = "| **Name** | Value |\n| --- | --- |\n| Item | `code` |",
+ textColor = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithText("Name").assertIsDisplayed()
+ composeTestRule.onNodeWithText("code").assertIsDisplayed()
+ composeTestRule.onNodeWithText("**Name**").assertDoesNotExist()
+ composeTestRule.onNodeWithText("`code`").assertDoesNotExist()
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt
index 6a1feeb2..836712b6 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt
@@ -443,7 +443,15 @@ fun MarkdownText(
}
is MdBlock.Table -> {
- MarkdownTable(block = block, textColor = textColor)
+ MarkdownTable(
+ block = block,
+ textColor = textColor,
+ latexMeasurer = latexMeasurer,
+ searchQuery = searchQuery,
+ isCurrentMatch = isCurrentMatch,
+ linkColor = linkColor,
+ highlights = highlights,
+ )
}
is MdBlock.Footnotes -> {
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt
index 9d6ef28c..2d2cc26c 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt
@@ -10,18 +10,19 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
+import com.hrm.latex.renderer.measure.LatexMeasurerState
+import com.m57.hermescontrol.theme.SearchHighlightColors
import com.m57.hermescontrol.util.BidiUtils
private val TABLE_COL_WIDTH = 140.dp
@@ -30,6 +31,11 @@ private val TABLE_COL_WIDTH = 140.dp
fun MarkdownTable(
block: MdBlock.Table,
textColor: Color,
+ latexMeasurer: LatexMeasurerState,
+ searchQuery: String,
+ isCurrentMatch: Boolean,
+ linkColor: Color,
+ highlights: SearchHighlightColors,
modifier: Modifier = Modifier,
) {
val isRtl =
@@ -49,18 +55,21 @@ fun MarkdownTable(
.padding(vertical = 4.dp),
) {
// Header row
- Row(modifier = Modifier.background(headerBg)) {
+ Row(modifier = Modifier.background(headerBg), verticalAlignment = Alignment.Top) {
block.header.forEachIndexed { idx, cell ->
- val cellRtl = BidiUtils.isRtlText(cell)
- Text(
- text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
- textAlign = tableTextAlign(alignments.getOrNull(idx)),
- color = textColor,
+ MarkdownInlineText(
+ text = cell,
+ textColor = textColor,
+ latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodySmall.copy(
fontWeight = FontWeight.Bold,
- textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr,
+ textAlign = tableTextAlign(alignments.getOrNull(idx)),
),
+ searchQuery = searchQuery,
+ isCurrentMatch = isCurrentMatch,
+ linkColor = linkColor,
+ highlights = highlights,
modifier =
Modifier
.width(TABLE_COL_WIDTH)
@@ -71,17 +80,20 @@ fun MarkdownTable(
HorizontalDivider(color = textColor.copy(alpha = 0.25f))
// Body rows
block.rows.forEach { row ->
- Row {
+ Row(verticalAlignment = Alignment.Top) {
row.forEachIndexed { idx, cell ->
- val cellRtl = BidiUtils.isRtlText(cell)
- Text(
- text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
- textAlign = tableTextAlign(alignments.getOrNull(idx)),
- color = textColor,
+ MarkdownInlineText(
+ text = cell,
+ textColor = textColor,
+ latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodySmall.copy(
- textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr,
+ textAlign = tableTextAlign(alignments.getOrNull(idx)),
),
+ searchQuery = searchQuery,
+ isCurrentMatch = isCurrentMatch,
+ linkColor = linkColor,
+ highlights = highlights,
modifier =
Modifier
.width(TABLE_COL_WIDTH)
@@ -95,9 +107,9 @@ fun MarkdownTable(
}
}
-private fun tableTextAlign(align: TableAlign?): TextAlign? =
+private fun tableTextAlign(align: TableAlign?): TextAlign =
when (align) {
TableAlign.CENTER -> TextAlign.Center
TableAlign.RIGHT -> TextAlign.End
- else -> null
+ else -> TextAlign.Start
}
From 1ba1a62f319df0ecbaf1aa14623f61548e638912 Mon Sep 17 00:00:00 2001
From: m57 <41645758+Hy4ri@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:05:27 +0300
Subject: [PATCH 017/107] fix(#1263): preserve nested inline Markdown styles
(#1268)
---
.../ui/chat/MarkdownHeadingRenderingTest.kt | 33 ++++
.../ui/chat/markdown/MarkdownInlineStyler.kt | 153 +++++++++++-------
.../ui/chat/MarkdownTextFeatureTest.kt | 58 +++++++
3 files changed, 188 insertions(+), 56 deletions(-)
create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt
new file mode 100644
index 00000000..ec36d0dc
--- /dev/null
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt
@@ -0,0 +1,33 @@
+package com.m57.hermescontrol.ui.chat
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class MarkdownHeadingRenderingTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun headingRendersCodeInsideBold() {
+ composeTestRule.setContent {
+ MaterialTheme {
+ MarkdownText(
+ text = "## **Set `foo` now**",
+ textColor = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithText("Set foo now").assertIsDisplayed()
+ composeTestRule.onNodeWithText("**Set `foo` now**").assertDoesNotExist()
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt
index 2aed606e..79c83b0c 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt
@@ -66,33 +66,84 @@ object MarkdownInlineStyler {
return buildAnnotatedString {
var i = 0
val src = text
+
while (i < src.length) {
when {
- // ***bold italic***
- src.startsWith("***", i) -> {
- val end = src.indexOf("***", i + 3)
- if (end != -1) {
- val raw = src.substring(i + 3, end)
- val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
- withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
- append(toAppend)
+ // Inline code is opaque to emphasis parsing, and delimiters must match by run length.
+ src[i] == '`' -> {
+ var runEnd = i
+ while (runEnd < src.length && src[runEnd] == '`') runEnd++
+ val runLength = runEnd - i
+ var end = runEnd
+ var matchingEnd = -1
+ while (end < src.length) {
+ if (src[end] == '`') {
+ var candidateEnd = end
+ while (candidateEnd < src.length && src[candidateEnd] == '`') candidateEnd++
+ if (candidateEnd - end == runLength) {
+ matchingEnd = end
+ break
+ }
+ end = candidateEnd
+ } else {
+ end++
}
- i = end + 3
+ }
+ if (matchingEnd >= 0) {
+ val raw = src.substring(runEnd, matchingEnd)
+ val content = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
+ withStyle(
+ SpanStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 13.sp,
+ background = textColor.copy(alpha = 0.08f),
+ ),
+ ) {
+ append(content)
+ }
+ i = matchingEnd + runLength
} else {
- append(src[i])
- i++
+ append(src.substring(i, runEnd))
+ i = runEnd
+ }
+ }
+
+ // Combined / nested emphasis delimiters.
+ src.startsWith("***", i) -> {
+ val end = src.indexOf("***", i + 3)
+ if (end >= 0) {
+ val raw = src.substring(i + 3, end)
+ val content = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
+ append(content)
+ }
+ i = end + 3
+ } else {
+ append("***")
+ i += 3
}
}
- // **bold**
src.startsWith("**", i) -> {
- val end = src.indexOf("**", i + 2)
+ var end = src.indexOf("**", i + 2)
+ if (end >= 0 && src.startsWith("***", end) &&
+ src.substring(i + 2, end).count { it == '*' } % 2 == 1
+ ) {
+ end++ // Inner italic closes with the first star of the trailing run.
+ }
if (end != -1) {
val raw = src.substring(i + 2, end)
- val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
- withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
- append(toAppend)
- }
+ val nested =
+ parseInlineSource(
+ raw,
+ textColor,
+ searchQuery,
+ isCurrentMatch,
+ linkColor,
+ highlights,
+ isRtl,
+ )
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(nested) }
i = end + 2
} else {
append(src[i])
@@ -116,16 +167,24 @@ object MarkdownInlineStyler {
}
}
- // *italic*
+ // *italic*; bold spans keep their styles while nested text is appended.
src.startsWith("*", i) -> {
- val end = src.indexOf('*', i + 1)
- if (end != -1 && end > i + 1) {
- val raw = src.substring(i + 1, end)
- val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
- withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
- append(toAppend)
- }
- i = end + 1
+ val close = src.indexOf('*', i + 1)
+ if (close > i + 1) {
+ val raw = src.substring(i + 1, close)
+ val italic = SpanStyle(fontStyle = FontStyle.Italic)
+ val nested =
+ parseInlineSource(
+ raw,
+ textColor,
+ searchQuery,
+ isCurrentMatch,
+ linkColor,
+ highlights,
+ isRtl,
+ )
+ withStyle(italic) { append(nested) }
+ i = close + 1
} else {
append(src[i])
i++
@@ -225,16 +284,20 @@ object MarkdownInlineStyler {
val urlEnd = src.indexOf(')', close + 2)
if (urlEnd != -1) {
val label = src.substring(i + 1, close)
- val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label
val url = src.substring(close + 2, urlEnd)
pushLink(LinkAnnotation.Url(url))
- withStyle(
- SpanStyle(
- color = linkColor,
- textDecoration = TextDecoration.Underline,
- ),
- ) {
- append(labelToAppend)
+ val labelText =
+ parseInlineSource(
+ label,
+ textColor,
+ searchQuery,
+ isCurrentMatch,
+ linkColor,
+ highlights,
+ isRtl,
+ )
+ withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) {
+ append(labelText)
}
pop()
i = urlEnd + 1
@@ -248,28 +311,6 @@ object MarkdownInlineStyler {
}
}
- // `inline code`
- src.startsWith("`", i) -> {
- val end = src.indexOf('`', i + 1)
- if (end != -1) {
- val raw = src.substring(i + 1, end)
- val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
- withStyle(
- SpanStyle(
- fontFamily = FontFamily.Monospace,
- fontSize = 13.sp,
- background = textColor.copy(alpha = 0.08f),
- ),
- ) {
- append(toAppend)
- }
- i = end + 1
- } else {
- append(src[i])
- i++
- }
- }
-
// bare URL
URL_PATTERN.matchAt(src, i) != null -> {
val match = URL_PATTERN.matchAt(src, i)!!
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt
index 6cd4de15..91bed590 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt
@@ -568,6 +568,64 @@ class MarkdownTextFeatureTest {
assertEquals("Run val x = 1 here", parsed.toString())
}
+ @Test
+ fun testNestedInlineStyles_preserveCodeAndEmphasisFormatting() {
+ val parsed = parseInline("**bold `code` and *italic***", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
+
+ assertEquals("bold code and italic", parsed.toString())
+ assertTrue(parsed.spanStyles.any { it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold })
+ assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
+ assertTrue(parsed.spanStyles.any { it.item.fontStyle == androidx.compose.ui.text.font.FontStyle.Italic })
+ val codeStart = parsed.indexOf("code")
+ assertTrue(
+ parsed.spanStyles.any {
+ it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold &&
+ it.start <= codeStart &&
+ it.end >= codeStart + 4
+ },
+ )
+ assertTrue(
+ parsed.spanStyles.any {
+ it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace &&
+ it.start <= codeStart &&
+ it.end >= codeStart + 4
+ },
+ )
+ }
+
+ @Test
+ fun testLinkLabel_nestedEmphasisAndCode() {
+ val parsed =
+ parseInline(
+ "[**bold `code`**](https://example.com)",
+ Color.Black,
+ "",
+ false,
+ Color.Blue,
+ DEFAULT_HIGHLIGHTS,
+ )
+
+ assertEquals("bold code", parsed.toString())
+ assertTrue(parsed.getLinkAnnotations(0, parsed.length).isNotEmpty())
+ assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
+ }
+
+ @Test
+ fun testCodeSpan_doesNotParseInnerEmphasis() {
+ val parsed = parseInline("`**literal**`", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
+
+ assertEquals("**literal**", parsed.toString())
+ assertFalse(parsed.spanStyles.any { it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold })
+ }
+
+ @Test
+ fun testInlineCode_matchesSameLengthBacktickRunAndPreservesUnmatchedRun() {
+ val parsed = parseInline("``a ` tick`` and `unfinished", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
+
+ assertEquals("a ` tick and `unfinished", parsed.toString())
+ assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
+ }
+
@Test
fun testArabicStartingWithEnglishWord_wrapsEnglishInLtrIsolate() {
val input = "Okay سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو"
From 0bc0117c89da10ed5ec1b017d95ccc40f7ef1146 Mon Sep 17 00:00:00 2001
From: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com>
Date: Sun, 27 Sep 2026 00:17:08 +1000
Subject: [PATCH 018/107] feat: add secure per-server custom headers for
Cloudflare Access (#1273)
---
README.md | 24 +-
.../hermescontrol/data/local/AuthManager.kt | 2 +
.../data/remote/OkHttpProvider.kt | 4 +
.../data/remote/ServerHeaders.kt | 136 ++++++++
.../ui/authlogin/AuthLoginScreen.kt | 9 +-
.../ui/common/CustomHeadersButton.kt | 196 ++++++++++++
.../ui/settings/SettingsViewModel.kt | 9 +
.../settings/components/ConnectionSection.kt | 1 +
.../components/ProfileEditorDialog.kt | 3 +
app/src/main/res/values/strings.xml | 12 +
.../data/local/AuthManagerTest.kt | 1 +
.../data/local/Issue647ProfileUrlTest.kt | 1 +
.../data/remote/ServerHeadersTest.kt | 297 ++++++++++++++++++
13 files changed, 693 insertions(+), 2 deletions(-)
create mode 100644 app/src/main/java/com/m57/hermescontrol/data/remote/ServerHeaders.kt
create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/common/CustomHeadersButton.kt
create mode 100644 app/src/test/java/com/m57/hermescontrol/data/remote/ServerHeadersTest.kt
diff --git a/README.md b/README.md
index 8fd98e94..76b4aefe 100644
--- a/README.md
+++ b/README.md
@@ -151,7 +151,29 @@ Tap **Sign in** on the landing screen and enter the dashboard host and port. The
| **Token only** | Dashboard on same machine (loopback) | **Token** — grab from `~/.hermes/dashboard-token.txt` or `~/.hermes/.env` (`HERMES_DASHBOARD_SESSION_TOKEN`). The app can also auto-extract it from the dashboard page |
| **Basic auth** | Dashboard on LAN with password gate | **Username** + **Password** (default `admin` / `hermes`). The app logs in, gets a session cookie, and mints a WebSocket ticket automatically |
-> The app communicates over plain HTTP — it's designed for **trusted local networks only**. Do not expose your Hermes gateway to untrusted networks.
+Use HTTPS for remote connections. Use HTTP only on a trusted local network.
+
+### Cloudflare Access and custom headers
+
+1. Enter your dashboard's HTTPS URL on the login screen.
+2. Tap **Custom headers**, then **Add Cloudflare headers**.
+3. Enter your service token's `CF-Access-Client-Id` and `CF-Access-Client-Secret` values.
+4. Tap **Save**. The app probes the dashboard again, then shows the Hermes login fields.
+
+Your [Cloudflare Access policy](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/)
+must accept the service token. These headers authenticate with Cloudflare; you still
+need to complete Hermes authentication.
+
+You can add other custom header names and values in the same editor. To edit or
+remove saved headers, open **Custom headers** from login or the saved connection's
+edit dialog in Settings. Values are masked and stored in encrypted preferences.
+Saving an empty list removes the headers for that URL.
+
+Headers are shared by profiles with the same server URL. They apply to probes,
+login, API and media requests, and WebSocket handshakes. Each URL's scheme, host,
+port, and path prefix limit where its headers are sent. Redirects outside that
+scope do not receive the credentials; WebSocket redirects are not followed.
+The app manages `Authorization`, `Cookie`, and transport headers itself.
### Connection profiles
diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
index d8d0fc33..464b130c 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
@@ -18,6 +18,7 @@ import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.ServerEndpoint
+import com.m57.hermescontrol.data.remote.ServerHeaders
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
@@ -215,6 +216,7 @@ object AuthManager {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
migrateLegacyDefaultIfNeeded(p)
+ ServerHeaders.initialize(p)
synchronized(this@AuthManager) {
readyPrefs = p
cachedToken = getTokenInternal(p)
diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt
index bda2d5aa..99a004eb 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt
@@ -33,6 +33,7 @@ object OkHttpProvider {
OkHttpClient
.Builder()
.cookieJar(resolveCookieJar())
+ .addNetworkInterceptor(ServerHeadersInterceptor())
.connectionPool(connectionPool)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
@@ -48,6 +49,9 @@ object OkHttpProvider {
.cookieJar(resolveCookieJar())
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(30, TimeUnit.SECONDS)
+ .addInterceptor(ServerHeadersInterceptor())
+ .followRedirects(false)
+ .followSslRedirects(false)
.build()
}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ServerHeaders.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ServerHeaders.kt
new file mode 100644
index 00000000..b0ead8c2
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ServerHeaders.kt
@@ -0,0 +1,136 @@
+package com.m57.hermescontrol.data.remote
+
+import android.content.SharedPreferences
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import okhttp3.HttpUrl
+import okhttp3.Interceptor
+import okhttp3.Response
+import java.io.IOException
+import java.util.Locale
+
+/** Validated header values. Never include credentials in diagnostics or toString(). */
+class CustomHeaders private constructor(
+ val entries: Map,
+) {
+ override fun toString(): String = "CustomHeaders(${entries.size} entries)"
+
+ companion object {
+ val EMPTY = CustomHeaders(emptyMap())
+ private val namePattern = Regex("[!#$%&'*+.^_`|~0-9A-Za-z-]+")
+ private val reserved =
+ setOf(
+ "authorization",
+ "proxy-authorization",
+ "cookie",
+ "host",
+ "connection",
+ "content-length",
+ "transfer-encoding",
+ "upgrade",
+ "te",
+ "trailer",
+ )
+
+ fun parse(rows: List>): CustomHeaders {
+ val result = linkedMapOf()
+ val names = mutableSetOf()
+ rows.filterNot { it.first.isBlank() && it.second.isEmpty() }.forEach { (rawName, value) ->
+ val name = rawName.trim()
+ val normalized = name.lowercase(Locale.ROOT)
+ // Do not expose OkHttp's validation exceptions: they can contain secret values.
+ require(namePattern.matches(name)) { "Invalid header name" }
+ require(normalized !in reserved && !normalized.startsWith("sec-websocket-")) {
+ "Header is managed by the app"
+ }
+ require(names.add(normalized)) { "Duplicate header name" }
+ require(value.all { it == '\t' || it in ' '..'~' }) { "Invalid header value" }
+ result[name] = value
+ }
+ return CustomHeaders(result.toMap())
+ }
+ }
+}
+
+/** Headers belong to a canonical server URL, including its reverse-proxy path. */
+class ScopedServerHeaders(
+ val baseUrl: HttpUrl,
+ val headers: CustomHeaders,
+) {
+ fun contains(url: HttpUrl): Boolean =
+ url.scheme == baseUrl.scheme && url.host == baseUrl.host && url.port == baseUrl.port &&
+ (
+ url.encodedPath.startsWith(baseUrl.encodedPath) ||
+ url.encodedPath == baseUrl.encodedPath.removeSuffix("/")
+ )
+}
+
+/** Uses AuthManager's encrypted preferences, never the plaintext server_store.json. */
+object ServerHeaders {
+ private const val KEY = "server_custom_headers"
+
+ @Volatile
+ private var configured: List = emptyList()
+ private var preferences: SharedPreferences? = null
+
+ @Synchronized
+ fun initialize(encryptedPreferences: SharedPreferences) {
+ val stored = encryptedPreferences.getString(KEY, null)
+ configured =
+ if (stored == null) {
+ emptyList()
+ } else {
+ val decoded =
+ try {
+ Json.decodeFromString