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#YDq&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>>(stored) + } catch (_: SerializationException) { + // JSON parser exceptions include the input, which contains credentials. + throw IOException("Could not read custom headers") + } + decoded.map { (url, values) -> + ScopedServerHeaders(ServerEndpoint.parseForBuild(url).baseUrl, CustomHeaders.parse(values.toList())) + } + } + preferences = encryptedPreferences + } + + fun get(baseUrl: HttpUrl): CustomHeaders = + configured.firstOrNull { it.baseUrl == baseUrl }?.headers ?: CustomHeaders.EMPTY + + fun forRequest(url: HttpUrl): ScopedServerHeaders? = + configured.filter { it.contains(url) }.maxByOrNull { it.baseUrl.encodedPath.length } + + /** Call on IO. Publish only after the encrypted write succeeds. */ + @Synchronized + fun save( + baseUrl: HttpUrl, + headers: CustomHeaders, + ) { + val prefs = checkNotNull(preferences) { "Server headers are not initialized" } + val updated = configured.filterNot { it.baseUrl == baseUrl } + ScopedServerHeaders(baseUrl, headers) + val serialized = Json.encodeToString(updated.associate { it.baseUrl.toString() to it.headers.entries }) + if (!prefs.edit().putString(KEY, serialized).commit()) throw IOException("Could not save custom headers") + configured = updated + } +} + +/** + * HTTP uses this as a network interceptor so redirects are checked before every exchange. + * OkHttp skips network interceptors for WebSockets, which use it as an application interceptor + * with redirects disabled. Credentials never follow a request outside its original server scope. + */ +class ServerHeadersInterceptor( + private val resolve: (HttpUrl) -> ScopedServerHeaders? = ServerHeaders::forRequest, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val scope = resolve(chain.call().request().url) ?: return chain.proceed(request) + val builder = request.newBuilder() + val withinScope = scope.contains(request.url) && resolve(request.url)?.baseUrl == scope.baseUrl + scope.headers.entries.forEach { (name, value) -> + if (withinScope) builder.header(name, value) else builder.removeHeader(name) + } + return chain.proceed(builder.build()) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt index d4c119f4..18c06a53 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.common.CustomHeadersButton @Composable fun AuthLoginScreen( @@ -162,7 +163,13 @@ fun AuthLoginScreen( singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - enabled = state.authMode == null, + enabled = state.authMode == null && !state.probing && !state.isLoading, + ) + + CustomHeadersButton( + baseUrl = state.baseUrl, + enabled = !state.probing && !state.isLoading, + onSaved = viewModel::probe, ) // Cleartext transport warning diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/CustomHeadersButton.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/CustomHeadersButton.kt new file mode 100644 index 00000000..aabcf604 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/CustomHeadersButton.kt @@ -0,0 +1,196 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.remote.CustomHeaders +import com.m57.hermescontrol.data.remote.ServerEndpoint +import com.m57.hermescontrol.data.remote.ServerHeaders +import com.m57.hermescontrol.theme.Spacing +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import java.io.IOException + +@Composable +fun CustomHeadersButton( + baseUrl: String, + enabled: Boolean = true, + onSaved: () -> Unit, +) { + val endpoint = remember(baseUrl) { runCatching { ServerEndpoint.parseForBuild(baseUrl) }.getOrNull() } + var editing by remember(baseUrl) { mutableStateOf(false) } + OutlinedButton( + onClick = { editing = true }, + enabled = enabled && endpoint != null, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.custom_headers_title)) + } + if (editing && endpoint != null) { + CustomHeadersDialog( + baseUrl = endpoint.baseUrl, + onDismiss = { editing = false }, + onSaved = { + editing = false + onSaved() + }, + ) + } +} + +@Composable +private fun CustomHeadersDialog( + baseUrl: HttpUrl, + onDismiss: () -> Unit, + onSaved: () -> Unit, +) { + var rows by remember { mutableStateOf(ServerHeaders.get(baseUrl).entries.toList()) } + var visible by remember { mutableStateOf(false) } + var saving by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var deleting by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + + AlertDialog( + onDismissRequest = { if (!saving) onDismiss() }, + title = { Text(stringResource(R.string.custom_headers_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + Text(baseUrl.toString(), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.custom_headers_description)) + rows.forEachIndexed { index, (name, value) -> + OutlinedTextField( + value = name, + onValueChange = { new -> + rows = rows.mapIndexed { i, row -> if (i == index) new to row.second else row } + error = null + }, + label = { Text(stringResource(R.string.custom_headers_name)) }, + singleLine = true, + enabled = !saving, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = value, + onValueChange = { new -> + rows = rows.mapIndexed { i, row -> if (i == index) row.first to new else row } + error = null + }, + label = { Text(stringResource(R.string.custom_headers_value)) }, + singleLine = true, + enabled = !saving, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = + if (visible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { visible = !visible }) { + Icon( + imageVector = + if (visible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = + stringResource( + if (visible) R.string.custom_headers_hide else R.string.custom_headers_show, + ), + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + TextButton(onClick = { deleting = index }, enabled = !saving) { + Text(stringResource(R.string.custom_headers_remove)) + } + } + OutlinedButton(onClick = { rows = rows + ("" to "") }, enabled = !saving) { + Text(stringResource(R.string.custom_headers_add)) + } + if (rows.isEmpty()) { + TextButton( + onClick = { + rows = listOf("CF-Access-Client-Id" to "", "CF-Access-Client-Secret" to "") + }, + enabled = !saving, + ) { + Text(stringResource(R.string.custom_headers_cloudflare)) + } + } + error?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { + Button( + enabled = !saving, + onClick = { + val headers = runCatching { CustomHeaders.parse(rows) }.getOrNull() + if (headers == null) { + error = R.string.custom_headers_invalid + } else { + saving = true + coroutineScope.launch { + try { + withContext(Dispatchers.IO) { ServerHeaders.save(baseUrl, headers) } + onSaved() + } catch (_: IOException) { + error = R.string.custom_headers_save_failed + } finally { + saving = false + } + } + } + }, + ) { Text(stringResource(R.string.action_save)) } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !saving) { Text(stringResource(R.string.action_cancel)) } + }, + ) + deleting?.let { index -> + AlertDialog( + onDismissRequest = { deleting = null }, + title = { Text(stringResource(R.string.custom_headers_remove)) }, + text = { Text(stringResource(R.string.custom_headers_remove_confirm)) }, + confirmButton = { + TextButton(onClick = { + rows = rows.filterIndexed { i, _ -> i != index } + deleting = null + error = null + }) { Text(stringResource(R.string.action_delete)) } + }, + dismissButton = { + TextButton(onClick = { deleting = null }) { Text(stringResource(R.string.action_cancel)) } + }, + ) + } +} 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 411f45bb..e2ebd227 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 @@ -265,6 +265,15 @@ class SettingsViewModel( ApiClient.rebuild() } + fun onCustomHeadersSaved() { + val editedEndpoint = ServerEndpoint.parseForBuild(_uiState.value.dialogProfileBaseUrl) + if (editedEndpoint.baseUrl == AuthManager.endpointForBuild().baseUrl) { + ApiClient.rebuild() + HermesWsClient.disconnect() + HermesWsClient.connect() + } + } + // ── Delete confirmation ────────────────────────────────────────────── fun requestDeleteProfile(profileId: String) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt index e1e86ed9..9200a2e7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt @@ -175,6 +175,7 @@ internal fun ConnectionSection( onTokenChange = viewModel::onDialogProfileTokenChange, onSave = viewModel::saveProfileFromDialog, onDismiss = viewModel::closeProfileDialog, + onHeadersSaved = viewModel::onCustomHeadersSaved, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt index 64137ab9..ab0207a1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.common.CustomHeadersButton @Composable internal fun ProfileEditorDialog( @@ -33,6 +34,7 @@ internal fun ProfileEditorDialog( onTokenChange: (String) -> Unit, onSave: () -> Unit, onDismiss: () -> Unit, + onHeadersSaved: () -> Unit, ) { val title = if (isEditing) { @@ -63,6 +65,7 @@ internal fun ProfileEditorDialog( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = Modifier.fillMaxWidth(), ) + CustomHeadersButton(baseUrl = baseUrl, onSaved = onHeadersSaved) if (error != null) { Text( text = error, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5fcd2512..0d0ff6d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1763,4 +1763,16 @@ Attachment error: %1$s Active workers No active workers + Custom headers + Saved securely for this server URL. Use Cloudflare Access service-token headers here. Hermes login is still required. + Header name + Header value + Add header + Add Cloudflare headers + Remove header + Remove this header? Save your changes to apply the removal. + Show header values + Hide header values + Use unique HTTP header names and single-line ASCII values. Authorization, Cookie, Host, and connection headers are managed by the app. + Could not save custom headers. Try again. diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt index 05c58e06..71449d7e 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt @@ -35,6 +35,7 @@ class AuthManagerTest { @Before fun setUp() { mockPrefs = mockk(relaxed = true) + every { mockPrefs.getString("server_custom_headers", null) } returns null mockEditor = mockk(relaxed = true) mockContext = mockk(relaxed = true) diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt index cf8fb9bc..afc5108a 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt @@ -46,6 +46,7 @@ class Issue647ProfileUrlTest { @Before fun setUp() { mockPrefs = mockk(relaxed = true) + every { mockPrefs.getString("server_custom_headers", null) } returns null mockEditor = mockk(relaxed = true) mockContext = mockk(relaxed = true) diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ServerHeadersTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ServerHeadersTest.kt new file mode 100644 index 00000000..be8f1367 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ServerHeadersTest.kt @@ -0,0 +1,297 @@ +package com.m57.hermescontrol.data.remote + +import android.content.SharedPreferences +import io.mockk.every +import io.mockk.mockk +import okhttp3.CookieJar +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class ServerHeadersTest { + private val preferences = mockk() + private val editor = mockk() + private var saved: String? = null + private lateinit var server: MockWebServer + private val headers = + CustomHeaders.parse( + listOf( + "CF-Access-Client-Id" to "test-id", + "CF-Access-Client-Secret" to "secret", + ), + ) + + @Before + fun setUp() { + every { preferences.getString(any(), null) } answers { saved } + every { preferences.edit() } returns editor + every { editor.putString(any(), any()) } answers { + saved = secondArg() + editor + } + every { editor.commit() } returns true + ServerHeaders.initialize(preferences) + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + saved = null + ServerHeaders.initialize(preferences) + server.shutdown() + } + + @Test + fun `protected probe login ticket API and media requests require both headers`() { + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + if (request.getHeader("CF-Access-Client-Id") == "test-id" && + request.getHeader("CF-Access-Client-Secret") == "secret" + ) { + MockResponse().setBody("{}") + } else { + MockResponse().setResponseCode(403) + } + } + val url = server.url("/hermes/") + val probe = Request.Builder().url(server.url("/hermes/api/status")).build() + OkHttpProvider.probe + .newCall(probe) + .execute() + .use { assertEquals(403, it.code) } + ServerHeaders.save(url, headers) + val paths = listOf("api/status", "auth/password-login", "api/auth/ws-ticket", "api/sessions", "files/image.png") + paths.forEach { path -> + val builder = Request.Builder().url(server.url("/hermes/$path")) + if (path.contains("login") || path.contains("ticket")) builder.post("{}".toRequestBody()) + listOf(OkHttpProvider.probe, OkHttpProvider.base).forEach { client -> + client.newCall(builder.build()).execute().use { assertEquals(200, it.code) } + } + } + ServerHeaders.save(url, CustomHeaders.EMPTY) + OkHttpProvider.probe + .newCall(probe) + .execute() + .use { assertEquals(403, it.code) } + } + + @Test + fun `custom headers coexist with Hermes bearer and cookie authentication`() { + ServerHeaders.save(server.url("/"), headers) + server.enqueue(MockResponse()) + val request = + Request + .Builder() + .url(server.url("/api/status")) + .header("Authorization", "Bearer hermes-token") + .header("Cookie", "hermes_session_at=session") + .build() + OkHttpProvider.base + .newBuilder() + .cookieJar(CookieJar.NO_COOKIES) + .build() + .newCall(request) + .execute() + .close() + val recorded = server.takeRequest() + assertEquals("test-id", recorded.getHeader("CF-Access-Client-Id")) + assertEquals("Bearer hermes-token", recorded.getHeader("Authorization")) + assertEquals("hermes_session_at=session", recorded.getHeader("Cookie")) + } + + @Test + fun `WebSocket upgrade carries custom headers and receives a message`() { + ServerHeaders.save(server.url("/hermes/"), headers) + val message = CountDownLatch(1) + server.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + webSocket.send("connected") + } + }, + ), + ) + val socket = + OkHttpProvider.websocket.newWebSocket( + Request.Builder().url(server.url("/hermes/api/ws?ticket=test")).build(), + object : WebSocketListener() { + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + if (text == "connected") message.countDown() + } + }, + ) + try { + assertTrue(message.await(5, TimeUnit.SECONDS)) + val recorded = server.takeRequest() + assertEquals("test-id", recorded.getHeader("CF-Access-Client-Id")) + assertEquals("secret", recorded.getHeader("CF-Access-Client-Secret")) + assertEquals("websocket", recorded.getHeader("Upgrade")) + } finally { + socket.cancel() + } + } + + @Test + fun `HTTP redirect to another configured server sends neither servers credentials`() { + MockWebServer().use { other -> + other.start() + ServerHeaders.save(server.url("/"), headers) + ServerHeaders.save(other.url("/"), CustomHeaders.parse(listOf("X-Other-Secret" to "other-secret"))) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", other.url("/api/status"))) + other.enqueue(MockResponse()) + OkHttpProvider.base + .newCall(Request.Builder().url(server.url("/api/status")).build()) + .execute() + .close() + assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret")) + val redirected = other.takeRequest() + assertNull(redirected.getHeader("CF-Access-Client-Secret")) + assertNull(redirected.getHeader("CF-Access-Client-Id")) + assertNull(redirected.getHeader("X-Other-Secret")) + } + } + + @Test + fun `same-server redirect preserves headers but escaping path prefix removes them`() { + ServerHeaders.save(server.url("/hermes/"), headers) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/hermes/next"))) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/outside"))) + server.enqueue(MockResponse()) + OkHttpProvider.base + .newCall(Request.Builder().url(server.url("/hermes/start")).build()) + .execute() + .close() + assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret")) + assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret")) + assertNull(server.takeRequest().getHeader("CF-Access-Client-Secret")) + } + + @Test + fun `redirect into a separately configured path does not inherit parent credentials`() { + ServerHeaders.save(server.url("/"), headers) + ServerHeaders.save(server.url("/public/"), CustomHeaders.EMPTY) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/public/api/status"))) + server.enqueue(MockResponse()) + OkHttpProvider.base + .newCall(Request.Builder().url(server.url("/start")).build()) + .execute() + .close() + assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret")) + assertNull(server.takeRequest().getHeader("CF-Access-Client-Secret")) + } + + @Test + fun `WebSocket redirect is not followed`() { + MockWebServer().use { other -> + other.start() + ServerHeaders.save(server.url("/"), headers) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", other.url("/api/ws"))) + val failed = CountDownLatch(1) + val socket = + OkHttpProvider.websocket.newWebSocket( + Request.Builder().url(server.url("/api/ws")).build(), + object : WebSocketListener() { + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + if (response?.code == 302) failed.countDown() + } + }, + ) + try { + assertTrue(failed.await(5, TimeUnit.SECONDS)) + assertEquals(0, other.requestCount) + } finally { + socket.cancel() + } + } + } + + @Test + fun `scope matches scheme host port and complete path segment`() { + val scope = ScopedServerHeaders("https://hermes.example/app/".toHttpUrl(), headers) + assertTrue(scope.contains("https://hermes.example/app/api/status".toHttpUrl())) + assertTrue(scope.contains("https://hermes.example/app".toHttpUrl())) + listOf( + "http://hermes.example/app/api/status", + "https://other.example/app/api/status", + "https://hermes.example:8443/app/api/status", + "https://hermes.example/application/api/status", + ).forEach { assertFalse(scope.contains(it.toHttpUrl())) } + } + + @Test + fun `saved credentials survive reload and an empty child scope blocks parent credentials`() { + ServerHeaders.save(server.url("/"), headers) + ServerHeaders.save(server.url("/public/"), CustomHeaders.EMPTY) + ServerHeaders.initialize(preferences) + assertEquals("secret", ServerHeaders.get(server.url("/")).entries["CF-Access-Client-Secret"]) + assertTrue( + ServerHeaders + .forRequest(server.url("/public/api/status"))!! + .headers.entries + .isEmpty(), + ) + } + + @Test + fun `failed storage write does not publish new credentials`() { + every { editor.commit() } returns false + assertThrows(IOException::class.java) { ServerHeaders.save(server.url("/"), headers) } + assertTrue(ServerHeaders.get(server.url("/")).entries.isEmpty()) + } + + @Test + fun `malformed stored JSON does not expose credentials in errors`() { + saved = "{\"secret\": broken" + val error = assertThrows(IOException::class.java) { ServerHeaders.initialize(preferences) } + assertFalse(error.stackTraceToString().contains("secret")) + } + + @Test + fun `reject invalid duplicate and managed headers without revealing values`() { + listOf( + listOf("Bad Name" to "secret"), + listOf("X-Test" to "secret\r\nInjected: bad"), + listOf("X-Test" to "secret", "x-test" to "secret"), + listOf("Authorization" to "secret"), + listOf("Cookie" to "secret"), + listOf("Host" to "secret"), + listOf("Sec-WebSocket-Key" to "secret"), + ).forEach { rows -> + val error = assertThrows(IllegalArgumentException::class.java) { CustomHeaders.parse(rows) } + assertFalse(error.message.orEmpty().contains("secret")) + } + assertFalse(headers.toString().contains("secret")) + } +} From 2084f9083dd6cd9408f11c2849b729750ec73f1d Mon Sep 17 00:00:00 2001 From: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:48:49 -0400 Subject: [PATCH 019/107] feat(#1247): slide-up lock + release/network fixes for voice notes (#1280) --- .../chat/components/VoiceNoteGestureTest.kt | 294 +++++++++ .../hermescontrol/data/remote/ApiClient.kt | 20 +- .../data/repository/VoiceNoteRepository.kt | 49 +- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 3 +- .../ui/chat/components/ChatComposer.kt | 614 +++++++++++------- .../ui/chat/components/ChatMediaLaunchers.kt | 43 +- .../ui/chat/components/ComposerToolbar.kt | 199 +++++- .../components/VoiceNoteRecordingPanel.kt | 199 ++++-- app/src/main/res/values/strings.xml | 6 +- .../repository/VoiceNoteRepositoryTest.kt | 36 + 10 files changed, 1108 insertions(+), 355 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt new file mode 100644 index 00000000..5c9a80c1 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt @@ -0,0 +1,294 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.Context +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.semantics.Role +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.assertIsFocused +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.text.input.TextFieldValue +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.ws.CommandCatalog +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * Gesture-level coverage for the Telegram-style voice-note mic: a held press + * arms the recorder, the release submits exactly once, sliding left cancels, + * sliding up locks hands-free, and the shared click handlers must never + * re-dispatch the dictation (mic tap) action for a hold. + * + * Regression: the action button's click handler fires on ANY stationary + * release — including one that ended a hold — so letting go of a voice note + * also launched the system speech-recognizer intent. The gesture loop now + * consumes the hold phase, and these tests pin that behavior down. + * + * The hold threshold runs on the compose test clock, so the tests advance + * [createAndroidComposeRule]'s mainClock instead of sleeping. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class VoiceNoteGestureTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private var micTaps = 0 + private var holdStarts = 0 + private var holdEnds = 0 + private var holdCancels = 0 + private var sendCount = 0 + private var locks = 0 + + /** + * Renders the real input bar with the launcher's contract: hold start/end + * flip the recording state, the lock callback flips the locked flag, and a + * cancel releases both — exactly like ChatMediaLaunchers does. + */ + private fun setComposer( + initiallyLocked: Boolean = false, + draft: String = "", + ) { + // The lock-hint tooltip hides after the first successful lock and + // persists that; start every run from a clean slate so the rendered + // UI is deterministic. + composeTestRule.activity + .getSharedPreferences("chat_voice_prefs", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + composeTestRule.setContent { + var locked by remember { mutableStateOf(initiallyLocked) } + var recording by remember { mutableStateOf(initiallyLocked) } + // Bottom-anchor the bar like the real chat screen, so floating + // overlays above the card render inside the captured frame. + Column(modifier = Modifier.fillMaxSize()) { + Spacer(modifier = Modifier.weight(1f)) + ChatInputBar( + inputFieldValue = TextFieldValue(draft), + onInputChange = {}, + onSend = { sendCount++ }, + onMicTap = { micTaps++ }, + isListening = false, + isAgentTyping = false, + isConnected = true, + commandCatalog = CommandCatalog(), + isSessionReady = true, + onMicHoldStart = { + holdStarts++ + recording = true + }, + onMicHoldEnd = { + holdEnds++ + recording = false + }, + onMicHoldCancel = { + holdCancels++ + locked = false + recording = false + }, + onMicLock = { + locks++ + locked = true + }, + isVoiceNoteLocked = locked, + isRecordingVoice = recording, + ) + } + } + } + + @Test + fun stationaryHoldRelease_sendsWithoutTriggeringMicTap() { + setComposer() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + // Mid-hold capture: the recording strip must be visible even though + // the mic button sits under the finger (device follow-up, #1247). + val shot = composeTestRule.onRoot().captureToImage().asAndroidBitmap() + File(composeTestRule.activity.getExternalFilesDir(null), "voice_note_recording.png") + .outputStream() + .use { shot.compress(Bitmap.CompressFormat.PNG, 100, it) } + // The lock hint must exist while holding (it sits above the card). + composeTestRule.onNodeWithText("Slide up to lock recording").assertExists() + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("release must submit the note", 1, holdEnds) + assertEquals( + "a held release must not re-dispatch the tap action", + 0, + micTaps, + ) + assertEquals("a submitted hold must not cancel", 0, holdCancels) + } + } + + @Test + fun quickTap_stillDispatchesMicTap() { + setComposer() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { + down(center) + up() + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("a quick tap must start dictation", 1, micTaps) + assertEquals(0, holdStarts) + assertEquals(0, holdEnds) + } + } + + @Test + fun holdSlideAway_cancelsWithoutSending() { + setComposer() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { moveBy(Offset(-240f, 0f)) } + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("sliding left must cancel the recording", 1, holdCancels) + assertEquals("a cancelled hold must not submit", 0, holdEnds) + assertEquals("a cancelled hold must not start dictation", 0, micTaps) + } + } + + @Test + fun holdSlideUp_locksWithoutSending() { + setComposer() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { moveBy(Offset(0f, -240f)) } + // Lifting the finger after the slide-up lock: target the root, because + // the action slot has already morphed into the send button. + composeTestRule.onRoot().performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("sliding up must lock the recording", 1, locks) + assertEquals("a locked hold must not submit on release", 0, holdEnds) + assertEquals("a locked hold must not cancel", 0, holdCancels) + assertEquals("a locked hold must not start dictation", 0, micTaps) + } + + composeTestRule.onNodeWithTag("voice_note_send_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("voice_note_send_button").performClick() + composeTestRule.runOnIdle { + assertEquals("the locked send button must dispatch the mic action", 1, micTaps) + assertEquals("sending must not re-lock", 1, locks) + } + } + + @Test + fun holdRelease_returnsFocusToTheInput() { + setComposer() + + composeTestRule.onNodeWithTag("chat_input").performClick() + composeTestRule.onNodeWithTag("chat_input").assertIsFocused() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + // The strip replaced the input field while recording; the send must + // hand focus back so the keyboard re-opens without an extra tap + // (device follow-up, #1247). + composeTestRule.onNodeWithTag("chat_input").assertIsFocused() + } + + @Test + fun lockedRecording_showsCancelActionAndRoutesCancel() { + setComposer(initiallyLocked = true) + + composeTestRule.onNodeWithTag("voice_note_recording_panel").assertIsDisplayed() + composeTestRule.onNodeWithTag("voice_note_cancel_button").assertIsDisplayed() + // Accessibility (review, PR #1280): the locked CANCEL is a real + // button. Its hit area is expanded by minimumInteractiveComponentSize + // (Compose keeps the visual text compact and widens the touch target), + // so the assertion pins the button role rather than layout bounds. + composeTestRule + .onNodeWithTag("voice_note_cancel_button") + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) + + val shot = composeTestRule.onRoot().captureToImage().asAndroidBitmap() + val file = + File(composeTestRule.activity.getExternalFilesDir(null), "voice_note_locked.png") + file.outputStream().use { shot.compress(Bitmap.CompressFormat.PNG, 100, it) } + + composeTestRule.onNodeWithTag("voice_note_cancel_button").performClick() + composeTestRule.runOnIdle { + assertEquals("cancel must cancel the recording", 1, holdCancels) + } + } + + @Test + fun holdReleaseWithDraft_doesNotRouteTapToDictation() { + // With a draft present the secondary mic ("mic_button") appears next to + // the input. Review, PR #1280: its gesture modifier is chained after + // the button's own clickable (the reverse of the action button), so a + // stationary hold-release must not leak a finger-up into onMicTap. + setComposer(draft = "draft") + val tapsBefore = micTaps + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("release must submit the note", 1, holdEnds) + assertEquals("a stationary hold-release must not fire a tap", tapsBefore, micTaps) + } + } + + @Test + fun lockedWithDraft_actionButtonFinishesTheVoiceNote() { + // Blocker, review, PR #1280: with a draft present the action button is + // a send arrow; once the recording is locked it must finish the voice + // note instead of sending the draft text. + setComposer(draft = "draft") + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { moveBy(Offset(0f, -240f)) } + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("voice_note_send_button").performClick() + composeTestRule.runOnIdle { + assertEquals("the tap must finish the voice note", 1, micTaps) + assertEquals("the draft text must not be sent", 0, sendCount) + } + } +} 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 369d9a43..584e816d 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 @@ -95,9 +95,12 @@ object ApiClient { * 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). + * the shared connection pool and the auth/profile interceptor stack. It + * applies [readTimeoutMs] to reads and writes and keeps transport-level + * retries on: they re-dial and can resend depending on when a failure + * struck, so they do not guarantee the absence of duplicate provider + * work — the risk is small, not zero. Application-level retry stays off + * (review, PR #1250 / #1280). */ fun transcriptionService(readTimeoutMs: Long): HermesApiService { val client = @@ -109,7 +112,16 @@ object ApiClient { .authenticator(TokenRefreshAuthenticator) .readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS) .writeTimeout(readTimeoutMs, TimeUnit.MILLISECONDS) - .retryOnConnectionFailure(false) + // Transport-level stale-socket rescue stays ON: the shared pool + // keeps connections far longer than the dashboard's keep-alive, + // so the first STT call after an idle spell can ride a socket + // the server just closed ("unexpected end of stream"). OkHttp + // re-dials and can resend depending on when the failure struck, + // and it may retry across routes — duplicate provider work is + // possible, though rare. Timeouts and 5xx are never replayed + // (safeApiCall still runs with retries = 0). Risk small, not + // zero (review, PR #1280). + .retryOnConnectionFailure(true) .build() return Retrofit .Builder() 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 index 3dabae8a..2c15a06d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt @@ -1,13 +1,17 @@ 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.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 kotlinx.coroutines.delay import java.io.File import java.io.IOException +import java.net.ConnectException +import java.net.UnknownHostException import java.util.Base64 /** @@ -33,6 +37,10 @@ const val VOICE_NOTE_MIME_TYPE = "audio/mp4" * - 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. + * - Connect-never-established failures (unreachable route, DNS blip — the + * usual suspects across Wi-Fi/cellular switches) get ONE delayed + * re-attempt: that request never reached the server, so no provider work + * is duplicated (device follow-up, #1247). * * Open for test substitution (mirrors [ChatPersistenceRepository]'s pattern). */ @@ -54,16 +62,23 @@ open class VoiceNoteRepository( ) } 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), - ) + val service = serviceProvider(transcriptionTimeoutMs(dataUrl)) + val result = requestTranscription(service, dataUrl) + // One bounded re-attempt, and only for connection-never-established + // failures — the one class where the request provably never reached + // the server, so no provider work is duplicated (device follow-up, + // #1247). + val finalResult = + if (result is NetworkResult.Failure && isConnectNeverEstablished(result.error)) { + delay(CONNECT_RETRY_DELAY_MS) + requestTranscription(service, dataUrl) + } else { + result } - return when (result) { + return when (finalResult) { is NetworkResult.Success -> { NetworkResult.Success( - result.data + finalResult.data ?.transcript .orEmpty() .trim(), @@ -71,11 +86,26 @@ open class VoiceNoteRepository( } is NetworkResult.Failure -> { - result + finalResult } } } + private suspend fun requestTranscription( + service: HermesApiService, + dataUrl: String, + ): NetworkResult = + safeApiCall(retries = 0) { + service.transcribeAudio( + AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE), + ) + } + + private fun isConnectNeverEstablished(error: NetworkError): Boolean { + val cause = (error as? NetworkError.Connection)?.cause ?: return false + return cause is ConnectException || cause is UnknownHostException + } + companion object { /** Floor for STT requests — desktop parity (`AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS`). */ const val MIN_REQUEST_TIMEOUT_MS = 180_000L @@ -85,6 +115,9 @@ open class VoiceNoteRepository( private const val BASE64_CHARS_PER_TIMEOUT_MS = 10L + /** One re-attempt window for connection-never-established failures. */ + private const val CONNECT_RETRY_DELAY_MS = 750L + /** * Timeout for the blocking STT endpoint, mirroring the desktop's * `audioTranscribeRequestTimeoutMs`: ~0.1ms of budget per base64 char 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 ee9d91da..49585ae6 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 @@ -908,9 +908,10 @@ fun ChatScreen( onMicHoldStart = mediaLaunchers.onMicHoldStart, onMicHoldEnd = mediaLaunchers.onMicHoldEnd, onMicHoldCancel = mediaLaunchers.onMicHoldCancel, + onMicLock = mediaLaunchers.onMicLock, isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote, isRecordingVoice = mediaLaunchers.isRecordingVoice, - voiceNoteAmplitude = mediaLaunchers.voiceNoteAmplitude, + isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked, onStopGeneration = { viewModel.interruptSession() }, isAgentTyping = state.isAgentTyping, isMainTurnBusy = state.isMainTurnBusy, 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 ea3651f2..79638974 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 android.content.Context import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically @@ -9,6 +10,7 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -16,11 +18,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -30,6 +35,7 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -47,9 +53,14 @@ 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -113,8 +124,9 @@ fun ChatInputBar( onMicHoldStart: () -> Unit = {}, onMicHoldEnd: () -> Unit = {}, onMicHoldCancel: () -> Unit = {}, + onMicLock: () -> Unit = {}, + isVoiceNoteLocked: Boolean = false, isRecordingVoice: Boolean = false, - voiceNoteAmplitude: State = remember { mutableStateOf(0f) }, onStopGeneration: () -> Unit = {}, ) { // Allow sending while the agent is mid-turn or awaiting approval: the @@ -130,260 +142,408 @@ fun ChatInputBar( // Attachment tray state var showAttachmentTray by remember { mutableStateOf(false) } + // The voice-note strip replaces the input field, so the IME closes the + // moment recording starts. When the strip goes away (send, delete, or + // cancel), focus returns to the input and the keyboard re-opens if it was + // up before — the next message must not need an extra tap (device + // follow-up, #1247). + val inputFocusRequester = remember { FocusRequester() } + val softwareKeyboard = LocalSoftwareKeyboardController.current + var inputWasFocused by remember { mutableStateOf(false) } + var restoreInputFocus by remember { mutableStateOf(false) } + // Snapshot BEFORE the launcher flips isRecordingVoice — by the time the + // recomposition swaps the field out, focus is already on its way to the + // root, so the effect alone cannot read it reliably. + val handleMicHoldStart = { + restoreInputFocus = inputWasFocused + onMicHoldStart() + } + // A hold can arm and still fail to start the recorder (permission denied, + // mic busy). Drop the focus snapshot then, so the end of some later + // recording cannot pop the keyboard on its behalf (review, PR #1280). + val handleMicHoldEnd = { + if (!isRecordingVoice) restoreInputFocus = false + onMicHoldEnd() + } + val handleMicHoldCancel = { + if (!isRecordingVoice) restoreInputFocus = false + onMicHoldCancel() + } + LaunchedEffect(isRecordingVoice) { + if (!isRecordingVoice && restoreInputFocus) { + restoreInputFocus = false + inputFocusRequester.requestFocus() + softwareKeyboard?.show() + } + } + // Telegram parity: the strip's hint slides and fades with the drag, so + // the gesture reports its 1 → 0 cancel fraction here. + val voiceSlideProgress = remember { mutableStateOf(1f) } + // The "Slide up to lock recording" tooltip shows until the first + // successful lock, then stays away (persisted, like Telegram). + val context = LocalContext.current + var voiceLockHintDone by remember { + mutableStateOf( + context + .getSharedPreferences(VOICE_PREFS_NAME, Context.MODE_PRIVATE) + .getBoolean(VOICE_LOCK_HINT_KEY, false), + ) + } + LaunchedEffect(isVoiceNoteLocked) { + if (isVoiceNoteLocked && !voiceLockHintDone) { + voiceLockHintDone = true + context + .getSharedPreferences(VOICE_PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(VOICE_LOCK_HINT_KEY, true) + .apply() + } + } val palette = composerPalette() BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false } - AnimatedVisibility( - visible = true, - enter = slideInVertically(initialOffsetY = { it }), - exit = slideOutVertically(targetOffsetY = { it }), - ) { - // One floating card holds the whole composer: suggestions, attachments, - // the input and the controls row. Flat fill, hairline edge, no shadow. - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - shape = MaterialTheme.shapes.large, - color = palette.card, - border = BorderStroke(width = 1.dp, color = palette.cardBorder), + Box { + AnimatedVisibility( + visible = true, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), ) { - Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) { - // Commands hidden from the suggestion menu — desktop/CLI-only and - // TUI-only commands that don't function on mobile (issue #574). - // Single source of truth: CommandBlocklist.UNSUPPORTED, which is - // also enforced at dispatch time (issue #576, deliverable #3). - val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED - val commandNames = - (commandCatalog.pairs.map { it[0] } + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop")) - .distinct() - .filter { it.lowercase() !in hiddenSlashDisplay } + // One floating card holds the whole composer: suggestions, attachments, + // the input and the controls row. Flat fill, hairline edge, no shadow. + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.large, + color = palette.card, + border = BorderStroke(width = 1.dp, color = palette.cardBorder), + ) { + Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) { + // Commands hidden from the suggestion menu — desktop/CLI-only and + // TUI-only commands that don't function on mobile (issue #574). + // Single source of truth: CommandBlocklist.UNSUPPORTED, which is + // also enforced at dispatch time (issue #576, deliverable #3). + val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED + val commandNames = + ( + commandCatalog.pairs.map { it[0] } + + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop") + ).distinct() + .filter { it.lowercase() !in hiddenSlashDisplay } - androidx.compose.animation.AnimatedVisibility( - visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "), - enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(), - exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(), - ) { - val filteredCommands = - ChatInputPolicy.sortSlashSuggestions( - commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) }, - slashUsageCounts, - ) - if (filteredCommands.isNotEmpty()) { - androidx.compose.material3.Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - border = - BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), - ), - ) { - LazyColumn( - modifier = Modifier.heightIn(max = 200.dp), + androidx.compose.animation.AnimatedVisibility( + visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "), + enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(), + exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(), + ) { + val filteredCommands = + ChatInputPolicy.sortSlashSuggestions( + commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) }, + slashUsageCounts, + ) + if (filteredCommands.isNotEmpty()) { + androidx.compose.material3.Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = + BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + ), ) { - items(filteredCommands, key = { it }) { cmd -> - DropdownMenuItem( - text = { - Text( - cmd, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - }, - onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) }, - ) + LazyColumn( + modifier = Modifier.heightIn(max = 200.dp), + ) { + items(filteredCommands, key = { it }) { cmd -> + DropdownMenuItem( + text = { + Text( + cmd, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + }, + onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) }, + ) + } } } } } - } - // Attachment preview chips - AnimatedVisibility( - visible = pendingAttachments.isNotEmpty(), - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - LazyRow( + // Attachment preview chips + AnimatedVisibility( + visible = pendingAttachments.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LazyRow( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed(pendingAttachments) { index, attachment -> + AttachmentChip( + attachment = attachment, + onPreview = { onPreviewAttachment(attachment) }, + onRemove = { onRemoveAttachment(index) }, + ) + } + } + } + + if (isConnected && !isSessionReady) { + Text( + text = + stringResource( + if (sessionPreparationFailed) { + R.string.chat_session_not_ready + } else { + R.string.chat_session_preparing + }, + ), + style = MaterialTheme.typography.labelSmall, + color = palette.placeholder, + modifier = Modifier.padding(horizontal = 20.dp).testTag("chat_session_preparing"), + ) + } + + // ── TOP ROW: Borderless input field ── + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + .padding(horizontal = 20.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, ) { - itemsIndexed(pendingAttachments) { index, attachment -> - AttachmentChip( - attachment = attachment, - onPreview = { onPreviewAttachment(attachment) }, - onRemove = { onRemoveAttachment(index) }, + val placeholderText = + when { + !isConnected -> { + stringResource(R.string.chat_input_placeholder_not_connected) + } + + ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> { + stringResource(R.string.chat_input_placeholder_queue) + } + + isAgentTyping -> { + stringResource(R.string.chat_input_placeholder_waiting) + } + + else -> { + stringResource(R.string.chat_input_placeholder_type_message) + } + } + + val ambientLayoutDirection = LocalLayoutDirection.current + val inputLayoutDirection = + remember(inputFieldValue.text, ambientLayoutDirection) { + BidiUtils.resolveLayoutDirection( + inputFieldValue.text, + fallback = ambientLayoutDirection, + ) + } + val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl + + if (isRecordingVoice) { + VoiceNoteRecordingPanel( + slideProgress = voiceSlideProgress, + locked = isVoiceNoteLocked, + onCancel = onMicHoldCancel, + modifier = Modifier.weight(1f), ) - } - } - } - - if (isConnected && !isSessionReady) { - Text( - text = - stringResource( - if (sessionPreparationFailed) { - R.string.chat_session_not_ready - } else { - R.string.chat_session_preparing - }, - ), - style = MaterialTheme.typography.labelSmall, - color = palette.placeholder, - modifier = Modifier.padding(horizontal = 20.dp).testTag("chat_session_preparing"), - ) - } - - // ── TOP ROW: Borderless input field ── - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val placeholderText = - when { - !isConnected -> { - stringResource(R.string.chat_input_placeholder_not_connected) - } - - ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> { - stringResource(R.string.chat_input_placeholder_queue) - } - - isAgentTyping -> { - stringResource(R.string.chat_input_placeholder_waiting) - } - - else -> { - stringResource(R.string.chat_input_placeholder_type_message) - } - } - - val ambientLayoutDirection = LocalLayoutDirection.current - val inputLayoutDirection = - remember(inputFieldValue.text, ambientLayoutDirection) { - BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection) - } - val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl - - 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, + } 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) + .focusRequester(inputFocusRequester) + .onFocusChanged { focusState -> + if (!isRecordingVoice) { + inputWasFocused = focusState.isFocused + } + }.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 + }, ) { - 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(), - ) + 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() } } - } - }, - ) + }, + ) + } } } + + // ── BOTTOM ROW: Toolbar ── + ComposerToolbar( + isConnected = isConnected, + currentSessionModel = currentSessionModel, + reasoningLevel = reasoningLevel, + isListening = isListening, + canSend = canSend, + showSend = hasDraft, + showQueue = showBusyActions, + onSend = onSend, + onQueue = { onBusySend(BusySendMode.QUEUE) }, + onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) }, + canInterrupt = canInterrupt, + onStopGeneration = onStopGeneration, + onAttachTap = { showAttachmentTray = !showAttachmentTray }, + onModelTap = onModelTap, + onReasoningSelected = onReasoningTap, + onMicTap = onMicTap, + onMicHoldStart = handleMicHoldStart, + onMicHoldEnd = handleMicHoldEnd, + onMicHoldCancel = handleMicHoldCancel, + onMicLock = onMicLock, + isVoiceNoteLocked = isVoiceNoteLocked, + onSlideProgress = { voiceSlideProgress.value = it }, + modifier = Modifier.testTag("chat_composer_toolbar"), + canDisableReasoning = canDisableReasoning, + supportsReasoning = supportsReasoning, + fastMode = fastMode, + fastSupported = fastSupported, + isFastModeChanging = isFastModeChanging, + onToggleFastMode = onToggleFastMode, + showModelProvider = showModelProvider, + reasoningWireLevel = reasoningWireLevel, + pendingReasoningLevel = pendingReasoningLevel, + isSessionReady = isSessionReady, + ) + + AttachmentTray( + visible = showAttachmentTray, + onDismissRequest = { showAttachmentTray = false }, + onCameraTap = onCameraTap, + onImageTap = onImageTap, + onFileTap = onFileTap, + ) } - - // ── BOTTOM ROW: Toolbar ── - ComposerToolbar( - isConnected = isConnected, - currentSessionModel = currentSessionModel, - reasoningLevel = reasoningLevel, - isListening = isListening, - canSend = canSend, - showSend = hasDraft, - showQueue = showBusyActions, - 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, - fastMode = fastMode, - fastSupported = fastSupported, - isFastModeChanging = isFastModeChanging, - onToggleFastMode = onToggleFastMode, - showModelProvider = showModelProvider, - reasoningWireLevel = reasoningWireLevel, - pendingReasoningLevel = pendingReasoningLevel, - isSessionReady = isSessionReady, - ) - - AttachmentTray( - visible = showAttachmentTray, - onDismissRequest = { showAttachmentTray = false }, - onCameraTap = onCameraTap, - onImageTap = onImageTap, - onFileTap = onFileTap, - ) } } + // Zero-height anchor so the tooltip and lock icon float above the + // card, over the message list, like Telegram's lock control. It sits + // outside the animated card because AnimatedVisibility clips its + // children to the animation bounds. + Box(modifier = Modifier.fillMaxWidth().height(0.dp)) { + VoiceNoteLockHintOverlay( + visible = isRecordingVoice && !isVoiceNoteLocked, + showTextHint = !voiceLockHintDone && voiceSlideProgress.value >= 0.8f, + modifier = + Modifier + .wrapContentHeight(align = Alignment.Top, unbounded = true) + .align(Alignment.TopEnd) + .offset(y = (-42).dp) + .padding(end = 12.dp), + ) + } } } +/** + * Telegram-style lock affordance floating over the composer while a voice + * note records: the outlined lock icon with the "Slide up to lock recording" + * tooltip beside it. The tooltip shows only until the first successful lock + * (persisted in [VOICE_PREFS_NAME]); the icon rides along until the recording + * locks hands-free. + */ +@Composable +private fun VoiceNoteLockHintOverlay( + visible: Boolean, + showTextHint: Boolean, + modifier: Modifier = Modifier, +) { + if (!visible) { + return + } + val palette = composerPalette() + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + if (showTextHint) { + Text( + text = stringResource(R.string.chat_voice_lock_hint), + style = MaterialTheme.typography.labelSmall, + color = palette.text, + maxLines = 1, + modifier = + Modifier + .background(palette.card, RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + } + Box( + modifier = + Modifier + .size(36.dp) + .background(palette.control, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = palette.placeholder, + modifier = Modifier.size(18.dp), + ) + } + } +} + +/** Shared prefs file for the voice-note UI. */ +private const val VOICE_PREFS_NAME = "chat_voice_prefs" + +/** Set after the first successful hands-free lock, silencing the hint. */ +private const val VOICE_LOCK_HINT_KEY = "voice_note_lock_hint_done" + /** * Reusable attachment chip composable for showing a pending attachment * with a remove button. 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 fe8dd7d8..1d8aa22f 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 @@ -14,7 +14,6 @@ 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 @@ -40,7 +39,7 @@ import java.util.Locale class ChatMediaLaunchers( val isListening: Boolean, val isRecordingVoice: Boolean, - val voiceNoteAmplitude: State, + val isVoiceNoteLocked: Boolean, val onMicTap: () -> Unit, val onCameraTap: () -> Unit, val onImageTap: () -> Unit, @@ -48,6 +47,7 @@ class ChatMediaLaunchers( val onMicHoldStart: () -> Unit = {}, val onMicHoldEnd: () -> Unit = {}, val onMicHoldCancel: () -> Unit = {}, + val onMicLock: () -> Unit = {}, ) @Composable @@ -85,28 +85,14 @@ fun rememberChatMediaLaunchers( 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 - } - } - } + // Locked = the recording continues after the finger lifts (slide up + // during the hold); the action button then submits it. + var isVoiceNoteLocked by remember { mutableStateOf(false) } fun finishVoiceRecording() { val recordedFile = voiceNoteRecorder.stop() isRecordingVoice = false + isVoiceNoteLocked = false if (recordedFile != null) { currentOnVoiceNoteRecorded(recordedFile) } @@ -264,6 +250,9 @@ fun rememberChatMediaLaunchers( if (isTranscribingVoiceNote) { // Single-flight: a server transcription owns the voice pipeline // until it lands (review, PR #1250). + } else if (isVoiceNoteLocked) { + // The locked recording's action button submits the note. + finishVoiceRecording() } else if (isRecordingVoice) { // A tap while recording discards the in-flight voice note. voiceNoteRecorder.cancel() @@ -334,6 +323,15 @@ fun rememberChatMediaLaunchers( voiceNoteRecorder.cancel() } isRecordingVoice = false + isVoiceNoteLocked = false + } + + // Locking keeps the recorder running once the finger lifts; only the + // panel's delete action or the action button can end it then. + val onMicLock: () -> Unit = { + if (voiceNoteRecorder.isActive) { + isVoiceNoteLocked = true + } } val onCameraTap: () -> Unit = { @@ -369,11 +367,11 @@ fun rememberChatMediaLaunchers( } } - return remember(isListening, isRecordingVoice, isTranscribingVoiceNote) { + return remember(isListening, isRecordingVoice, isVoiceNoteLocked, isTranscribingVoiceNote) { ChatMediaLaunchers( isListening = isListening || isRecordingVoice, isRecordingVoice = isRecordingVoice, - voiceNoteAmplitude = voiceNoteAmplitude, + isVoiceNoteLocked = isVoiceNoteLocked, onMicTap = onMicTap, onCameraTap = onCameraTap, onImageTap = onImageTap, @@ -381,6 +379,7 @@ fun rememberChatMediaLaunchers( onMicHoldStart = onMicHoldStart, onMicHoldEnd = onMicHoldEnd, onMicHoldCancel = onMicHoldCancel, + onMicLock = onMicLock, ) } } 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 9fcd992b..a85c3699 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 @@ -61,8 +61,12 @@ 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.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction @@ -127,6 +131,9 @@ fun ComposerToolbar( onMicHoldStart: () -> Unit = {}, onMicHoldEnd: () -> Unit = {}, onMicHoldCancel: () -> Unit = {}, + onMicLock: () -> Unit = {}, + isVoiceNoteLocked: Boolean = false, + onSlideProgress: (Float) -> Unit = {}, onStopGeneration: () -> Unit = {}, ) { var showReasoningMenu by remember { mutableStateOf(false) } @@ -134,36 +141,74 @@ fun ComposerToolbar( 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. + // Telegram-style voice notes: hold to record, release to send, slide left + // to cancel, slide up to lock hands-free. 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 currentIsVoiceNoteLocked = rememberUpdatedState(isVoiceNoteLocked) val currentOnMicHoldStart = rememberUpdatedState(onMicHoldStart) val currentOnMicHoldEnd = rememberUpdatedState(onMicHoldEnd) val currentOnMicHoldCancel = rememberUpdatedState(onMicHoldCancel) + val currentOnMicLock = rememberUpdatedState(onMicLock) + val currentOnSlideProgress = rememberUpdatedState(onSlideProgress) + // The mic button is under the thumb during the gesture, so the outcome — + // lock or cancel — must be felt as well as seen. + val haptic = LocalHapticFeedback.current + // Telegram's slide-to-cancel span: a third of the composer width, capped + // at [MAX_CANCEL_DISTANCE_DP]. The gesture modifier sits on the mic button + // itself (only ~40 dp wide), so the span derives from the screen width. + val cancelDistancePx = + with(LocalDensity.current) { + minOf( + LocalConfiguration.current.screenWidthDp.dp + .toPx() * 0.35f, + MAX_CANCEL_DISTANCE_DP.dp.toPx(), + ) + } val flatMicGesture = Modifier.pointerInput(Unit) { micHoldHandler( isEnabled = { - currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value + currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value && + !currentIsVoiceNoteLocked.value }, onHoldStart = { currentOnMicHoldStart.value() }, onHoldEnd = { currentOnMicHoldEnd.value() }, - onHoldCancel = { currentOnMicHoldCancel.value() }, + onHoldCancel = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + currentOnMicHoldCancel.value() + }, + onHoldLock = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + currentOnMicLock.value() + }, + onSlideProgress = { currentOnSlideProgress.value(it) }, + cancelDistance = cancelDistancePx, ) } val actionMicGesture = Modifier.pointerInput(Unit) { micHoldHandler( isEnabled = { - currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value + currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value && + !currentIsVoiceNoteLocked.value }, onHoldStart = { currentOnMicHoldStart.value() }, onHoldEnd = { currentOnMicHoldEnd.value() }, - onHoldCancel = { currentOnMicHoldCancel.value() }, + onHoldCancel = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + currentOnMicHoldCancel.value() + }, + onHoldLock = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + currentOnMicLock.value() + }, + onSlideProgress = { currentOnSlideProgress.value(it) }, + cancelDistance = cancelDistancePx, ) } @@ -488,19 +533,30 @@ fun ComposerToolbar( ) } } else { - FilledIconButton( - onClick = onMicTap, - enabled = isConnected, - colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette), + // Plain Box, not FilledIconButton: the hold gesture must sit + // innermost in the pointer-modifier chain so it consumes the + // release before the tap handler — impossible while the button + // supplies its own clickable (review, PR #1280). Same + // arrangement as the action button below. + val micColors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette) + Box( modifier = Modifier .size(ControlSize) - .testTag(if (isListening) "mic_stop_button" else "mic_button") + .clip(CircleShape) + .background( + if (isConnected) micColors.containerColor else micColors.disabledContainerColor, + ).combinedClickable( + enabled = isConnected, + onClick = onMicTap, + ).testTag(if (isListening) "mic_stop_button" else "mic_button") .then(flatMicGesture), + contentAlignment = Alignment.Center, ) { Icon( imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic, contentDescription = if (isListening) "Stop listening" else "Mic", + tint = if (isConnected) micColors.contentColor else micColors.disabledContentColor, ) } } @@ -527,7 +583,17 @@ fun ComposerToolbar( // Action button — send when a send is possible, mic / stop otherwise val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send) - val actionEnabled = if (showSend) canSend else isConnected + // While a recording is locked this button finishes the voice note, so + // it must stay enabled even when the draft alone would not send + // (review, PR #1280). + val actionEnabled = + if (isVoiceNoteLocked) { + true + } else if (showSend) { + canSend + } else { + isConnected + } Box( modifier = Modifier @@ -540,7 +606,7 @@ fun ComposerToolbar( palette.action }, ).semantics { - if (showSend && showQueue && canSend) { + if (showSend && showQueue && canSend && !isVoiceNoteLocked) { customActions = listOf( CustomAccessibilityAction(stopAndSendLabel) { @@ -553,15 +619,23 @@ fun ComposerToolbar( enabled = actionEnabled, onClick = { when { + // A locked recording always finishes with this + // button, draft or agent state regardless + // (review, PR #1280). + isVoiceNoteLocked -> onMicTap() + canInterrupt -> onStopGeneration() + showSend -> onSend() + else -> onMicTap() } }, - onLongClick = if (showSend && showQueue) onStopAndSend else null, - onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null, + onLongClick = if (showSend && showQueue && !isVoiceNoteLocked) onStopAndSend else null, + onLongClickLabel = if (showSend && showQueue && !isVoiceNoteLocked) stopAndSendLabel else null, ).testTag( when { + isVoiceNoteLocked -> "voice_note_send_button" canInterrupt -> "stop_button" showSend -> "send_button" isListening -> "mic_stop_button" @@ -573,6 +647,7 @@ fun ComposerToolbar( Crossfade( targetState = when { + isVoiceNoteLocked -> ActionGlyph.SEND canInterrupt -> ActionGlyph.STOP showSend -> ActionGlyph.SEND isListening -> ActionGlyph.STOP @@ -584,7 +659,12 @@ fun ComposerToolbar( ActionGlyph.SEND -> { Icon( imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.chat_send_desc), + contentDescription = + if (isVoiceNoteLocked) { + stringResource(R.string.chat_voice_send_desc) + } else { + stringResource(R.string.chat_send_desc) + }, tint = palette.onAction, ) } @@ -720,36 +800,53 @@ fun buildReasoningLabel( /** * 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. + * records a voice note, release sends it, dragging into the cancel zone + * cancels, and sliding up past [MIC_SLIDE_LOCK_DP] locks the recording + * hands-free. Short taps use the control's normal click handler for dictation, + * so touch and accessibility actions share one callback path. + * + * Distances and outcomes mirror Telegram's recorder: the full cancel distance + * is a third of the composer capped at [MAX_CANCEL_DISTANCE_DP]; releasing + * below [CANCEL_RELEASE_ALPHA] of it cancels (dragging back out before release + * un-cancels); dragging all the way cancels mid-gesture; [onSlideProgress] + * reports the 1 → 0 drag fraction so the recording strip can track and fade + * its hint; locking only arms while the gesture stays clear of the cancel zone + * ([LOCK_MIN_ALPHA]). * * 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. + * + * Once the hold phase arms, every event is consumed: the shared click handler + * otherwise also observes the stationary release that ends a hold (there is no + * drag to cancel it), and re-dispatches the tap action — the system dictation + * intent launched every time a voice note was sent (regression, #1247). */ private suspend fun PointerInputScope.micHoldHandler( isEnabled: () -> Boolean, onHoldStart: () -> Unit, onHoldEnd: () -> Unit, onHoldCancel: () -> Unit, + onHoldLock: () -> Unit, + onSlideProgress: (Float) -> Unit, + cancelDistance: Float, ) { - val cancelSlop = MIC_SLIDE_CANCEL_DP.dp.toPx() + val lockSlop = MIC_SLIDE_LOCK_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 - } + fun slideAlpha(position: Offset): Float = + (1f + (position.x - down.position.x) / cancelDistance).coerceIn(0f, 1f) - // Phase 1 — a finger-up before the threshold is a plain tap. + fun slidUp(position: Offset): Boolean = position.y - down.position.y < -lockSlop + + // Phase 1 — a finger-up before the threshold is a plain tap, left to + // the click handler. Dragging into the cancel zone aborts the gesture. + // Events are not consumed yet, so the tap path stays intact. var earlyUp = false var gestureAborted = false withTimeoutOrNull(HOLD_TO_RECORD_THRESHOLD_MS) { @@ -765,7 +862,7 @@ private suspend fun PointerInputScope.micHoldHandler( earlyUp = true return@withTimeoutOrNull } - if (change.isConsumed || slidAway(change.position)) { + if (change.isConsumed || slideAlpha(change.position) < CANCEL_RELEASE_ALPHA) { gestureAborted = true return@withTimeoutOrNull } @@ -779,23 +876,44 @@ private suspend fun PointerInputScope.micHoldHandler( } // Phase 2 — the threshold passed with the finger down: record until - // the finger lifts (send) or slides away (cancel). No timeout here. + // the finger lifts (send, unless dragged into the cancel zone), drags + // all the way left (cancel), or slides up (lock hands-free). onHoldStart() var send = false + var lock = false try { while (true) { val event = awaitPointerEvent() val change = event.changes.firstOrNull { it.id == down.id } ?: break + val alpha = slideAlpha(change.position) + onSlideProgress(alpha) if (!change.pressed) { - send = true + change.consume() + send = alpha >= CANCEL_RELEASE_ALPHA break } - if (change.isConsumed || slidAway(change.position)) { + if (change.isConsumed) { + change.consume() break } + if (alpha <= 0f) { + change.consume() + break + } + if (slidUp(change.position) && alpha >= LOCK_MIN_ALPHA) { + lock = true + change.consume() + break + } + change.consume() } } finally { - if (send) onHoldEnd() else onHoldCancel() + onSlideProgress(1f) + when { + lock -> onHoldLock() + send -> onHoldEnd() + else -> onHoldCancel() + } } } } @@ -803,5 +921,14 @@ private suspend fun PointerInputScope.micHoldHandler( /** 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 +/** Sliding this far up from the mic button locks the recording hands-free. */ +private const val MIC_SLIDE_LOCK_DP = 57 + +/** Full slide-to-cancel distance: a third of the composer (Telegram). */ +private const val MAX_CANCEL_DISTANCE_DP = 140 + +/** Releasing below this drag fraction of the cancel distance cancels. */ +private const val CANCEL_RELEASE_ALPHA = 0.45f + +/** Locking only arms while the gesture stays clear of the cancel zone. */ +private const val LOCK_MIN_ALPHA = 0.7f 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 index 1cca004a..a214b443 100644 --- 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 @@ -1,51 +1,79 @@ package com.m57.hermescontrol.ui.chat.components import android.os.SystemClock +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints 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.offset 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.material3.minimumInteractiveComponentSize 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.draw.alpha +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import kotlinx.coroutines.delay +import kotlin.math.roundToInt /** * 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. + * voice note is being recorded. Layout mirrors Telegram's recorder: a blinking + * red dot and the elapsed timer sit on the left; "◀ Slide to cancel" (with the + * word "cancel" emphasized) sits centered to their right, gently nudging back + * and forth, and slides left and fades as the gesture drags toward cancel + * ([slideProgress] runs 1 → 0 across that drag). Once the gesture slides up to + * lock, the hint is replaced by a bold tappable "CANCEL" and the action button + * submits the note. */ @Composable internal fun VoiceNoteRecordingPanel( - amplitude: State, + slideProgress: State, modifier: Modifier = Modifier, + locked: Boolean = false, + onCancel: () -> Unit = {}, ) { val palette = composerPalette() + val density = LocalDensity.current + val haptic = LocalHapticFeedback.current var elapsedSeconds by remember { mutableStateOf(0L) } LaunchedEffect(Unit) { val startedAt = SystemClock.elapsedRealtime() @@ -54,71 +82,130 @@ internal fun VoiceNoteRecordingPanel( 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) - } - } - } + // Telegram blinks the record dot on a 1200 ms cycle. + val blink = rememberInfiniteTransition(label = "voice_dot") + val dotAlpha by + blink.animateFloat( + initialValue = 1f, + targetValue = 0.35f, + animationSpec = infiniteRepeatable(tween(600, easing = LinearEasing), RepeatMode.Reverse), + label = "voice_dot_alpha", + ) + // ...and nudges the cancel hint ±6 dp while the gesture is idle (Telegram + // moves it 3 dp / 250 ms), so the slide affordance announces itself. + val nudgeDp by + blink.animateFloat( + initialValue = -6f, + targetValue = 6f, + animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing), RepeatMode.Reverse), + label = "voice_hint_nudge", + ) + val progress = slideProgress.value.coerceIn(0f, 1f) 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), - ) + modifier = Modifier.size(28.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .size(12.dp) + .background(MaterialTheme.colorScheme.error.copy(alpha = dotAlpha), CircleShape), + ) + } + Spacer(modifier = Modifier.width(6.dp)) Text( text = formatElapsed(elapsedSeconds), style = MaterialTheme.typography.labelLarge, - color = palette.text, + fontWeight = FontWeight.Bold, + color = palette.placeholder, ) - Row( - modifier = Modifier.height(24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), + BoxWithConstraints( + modifier = + Modifier + .weight(1f) + .clipToBounds(), + contentAlignment = Alignment.Center, ) { - val filler = VOICE_NOTE_BARS - levels.size - repeat(VOICE_NOTE_BARS) { index -> - val level = if (index < filler) 0f else levels[index - filler] - Box( + val containerWidthPx = with(density) { maxWidth.toPx() } + if (locked) { + Text( + text = stringResource(R.string.chat_voice_cancel).uppercase(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = palette.text, modifier = Modifier - .width(3.dp) - .height((4f + 18f * level).dp) - .background(palette.placeholder, RoundedCornerShape(2.dp)), + .minimumInteractiveComponentSize() + .clickable(role = Role.Button) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onCancel() + }.testTag("voice_note_cancel_button"), ) + } else { + Row( + modifier = + Modifier + .offset { + val shift = -containerWidthPx * 0.25f * (1f - progress) + val nudge = nudgeDp * density.density * progress + IntOffset((shift + nudge).roundToInt(), 0) + }.alpha(progress), + verticalAlignment = Alignment.CenterVertically, + ) { + CancelChevron(tint = palette.placeholder) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = slideToCancelText(palette), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + ) + } } } } } -/** Number of level-meter bars in the recording panel. */ -private const val VOICE_NOTE_BARS = 14 +/** "Slide to cancel" with the cancel word carrying the visual emphasis. */ +@Composable +private fun slideToCancelText(palette: ComposerPalette) = + buildAnnotatedString { + // Two resources so translators control both parts independently; the + // space between them is added here (review, PR #1280). + withStyle(SpanStyle(color = palette.placeholder)) { + append(stringResource(R.string.chat_voice_slide_to_cancel_prefix)) + append(" ") + } + withStyle(SpanStyle(color = palette.text, fontWeight = FontWeight.Bold)) { + append(stringResource(R.string.chat_voice_slide_to_cancel_action)) + } + } + +/** The small drawn left chevron that precedes the cancel hint. */ +@Composable +private fun CancelChevron(tint: Color) { + Canvas(modifier = Modifier.size(width = 4.dp, height = 10.dp)) { + val stroke = 1.6.dp.toPx() + val midY = size.height / 2f + val path = + Path().apply { + moveTo(size.width - stroke / 2f, midY - 5.dp.toPx()) + lineTo(stroke / 2f, midY) + lineTo(size.width - stroke / 2f, midY + 5.dp.toPx()) + } + drawPath( + path = path, + color = tint, + style = Stroke(width = stroke, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) + } +} /** Formats elapsed recording time as m:ss. */ private fun formatElapsed(seconds: Long): String { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d0ff6d3..968d3120 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,8 +4,12 @@ Send Listening… + Cancel + Slide up to lock recording Could not start voice recording - Slide to cancel + Send voice note + cancel + Slide to Stop generating Microphone permission is required for voice input Speech recognition is not available on this device 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 index e1387b9a..1dce6cb8 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt @@ -16,7 +16,9 @@ import org.junit.Assert.assertTrue import org.junit.Test import retrofit2.Response import java.io.File +import java.net.ConnectException import java.net.SocketTimeoutException +import java.net.UnknownHostException class VoiceNoteRepositoryTest { private val api = mockk() @@ -73,6 +75,40 @@ class VoiceNoteRepositoryTest { coVerify(exactly = 1) { api.transcribeAudio(any()) } } + @Test + fun transcribe_retriesOnceWhenTheConnectNeverEstablished() = + runTest { + // A connect failure means the request never reached the server, so + // one re-attempt is safe and rescues Wi-Fi/cellular handoffs that + // surface as "Network is unreachable" (device follow-up, #1247). + val file = tempVoiceFile() + var attempts = 0 + coEvery { api.transcribeAudio(any()) } coAnswers { + attempts++ + if (attempts == 1) { + throw ConnectException("failed to connect to /192.168.1.10:8000") + } + Response.success(AudioTranscriptionResponse(ok = true, transcript = " hi ")) + } + + val result = VoiceNoteRepository { _ -> api }.transcribe(file) + + assertEquals("hi", (result as NetworkResult.Success).data) + coVerify(exactly = 2) { api.transcribeAudio(any()) } + } + + @Test + fun transcribe_givesUpAfterOneConnectRetry() = + runTest { + val file = tempVoiceFile() + coEvery { api.transcribeAudio(any()) } throws UnknownHostException("no dns") + + val result = VoiceNoteRepository { _ -> api }.transcribe(file) + + assertTrue(result is NetworkResult.Failure) + coVerify(exactly = 2) { api.transcribeAudio(any()) } + } + @Test fun transcribe_resolvesTheServiceAtCallTime_soAConnectionSwitchUsesTheNewService() = runTest { From 89858fc35d2a731295d9b5b6dd50fc62e5c98317 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:06:58 +0300 Subject: [PATCH 020/107] fix(#1281): send connector owner instead of top-level session_id (#1294) --- .../data/ws/ConnectionOperationParser.kt | 17 +++++- .../data/ws/ConnectorRepository.kt | 7 ++- .../chat/ChatConnectionOperationDelegate.kt | 28 +++++++-- .../chat/components/ConnectionSetupSheet.kt | 20 +++++- .../ws/ConnectionOperationEventParserTest.kt | 34 +++++++++++ .../data/ws/ConnectorRepositoryTest.kt | 16 +++-- .../ChatConnectionOperationDelegateTest.kt | 61 ++++++++++++++++++- 7 files changed, 165 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt index b9748882..c7c83123 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt @@ -24,7 +24,7 @@ object ConnectionOperationParser { return null } return ConnectionOperationSnapshot( - sessionId = (sessionId ?: payload["session_id"] as? String)?.takeIf { it.isNotBlank() }, + sessionId = resolveSessionId(payload, sessionId), opId = opId, seq = seq, deadlineAt = deadlineAt, @@ -39,6 +39,21 @@ object ConnectionOperationParser { ) } + /** + * Session that owns the operation. Since hermes-agent v0.21.5 updates carry `owner` (#1281): + * session-owned ones name their session; account-owned ones never bind to a chat. + */ + private fun resolveSessionId( + payload: Map, + envelopeSessionId: String?, + ): String? { + val owner = payload["owner"] as? Map<*, *> + if (owner?.get("type") == "account") return null + val ownerSessionId = (owner?.get("session_id") as? String).takeIf { owner?.get("type") == "session" } + return listOf(envelopeSessionId, ownerSessionId, payload["session_id"] as? String) + .firstOrNull { !it.isNullOrBlank() } + } + private fun parseTarget(raw: Map<*, *>?): ConnectionOperationTarget? { val map = raw ?: return null val name = (map["name"] as? String)?.trim() ?: return null diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt index 1c39ce40..a6535881 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt @@ -32,6 +32,9 @@ interface ConnectorRepository { if (slug.isNullOrBlank()) return false return SLUG_REGEX.matches(slug) } + + /** `owner` discriminated union required by connector RPCs since hermes-agent v0.21.5 (#1281). */ + fun sessionOwner(sessionId: String): Map = mapOf("type" to "session", "session_id" to sessionId) } } @@ -61,7 +64,7 @@ class HermesConnectorRepository( } return try { - val params = mapOf("session_id" to sessionId) + val params = mapOf("owner" to ConnectorRepository.sessionOwner(sessionId)) val result = rpcRequest(WsMethods.CONNECTORS_LIST, params) ConnectorParser.parseListResult(result) } catch (e: CancellationException) { @@ -105,7 +108,7 @@ class HermesConnectorRepository( return try { val params = mapOf( - "session_id" to sessionId, + "owner" to ConnectorRepository.sessionOwner(sessionId), "connectors" to connectors, "reconnect" to reconnect, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt index 7d03246f..0166a881 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt @@ -1,6 +1,9 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.ws.ConnectorParser +import com.m57.hermescontrol.data.ws.ConnectorRepository import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsMethods import kotlinx.coroutines.CancellationException @@ -167,7 +170,7 @@ class ChatConnectionOperationDelegate( method = WsMethods.CONNECTION_RESPOND, params = mapOf( - "session_id" to requireSessionId(), + "owner" to ConnectorRepository.sessionOwner(requireSessionId()), "op_id" to snapshot.opId, "result" to mapOf("targets" to listOf(answer)), ), @@ -180,7 +183,7 @@ class ChatConnectionOperationDelegate( method = WsMethods.CONNECTION_RESPOND, params = mapOf( - "session_id" to requireSessionId(), + "owner" to ConnectorRepository.sessionOwner(requireSessionId()), "op_id" to snapshot.opId, "result" to mapOf("settled_by" to "continue"), ), @@ -193,7 +196,7 @@ class ChatConnectionOperationDelegate( val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return dispatch( method = WsMethods.CONNECTORS_OPERATION_WAKE, - params = mapOf("session_id" to requireSessionId(), "op_id" to snapshot.opId), + params = mapOf("owner" to ConnectorRepository.sessionOwner(requireSessionId()), "op_id" to snapshot.opId), clearOnSuccess = true, unknownOperationSettles = true, ) @@ -225,7 +228,7 @@ class ChatConnectionOperationDelegate( _state.value = _state.value.copy( pendingAction = null, - error = ConnectionOperationError("request_failed"), + error = operationError(error), ) } } @@ -245,6 +248,16 @@ class ChatConnectionOperationDelegate( return current } + /** Typed, sanitized error: ownership/runtime failures cannot succeed on retry (#1281). */ + private fun operationError(error: Exception): ConnectionOperationError { + val rpc = error as? HermesWsClient.HermesRpcException ?: return ConnectionOperationError(REQUEST_FAILED) + return when (ConnectorParser.mapRpcError(code = rpc.code, data = rpc.data)) { + is ConnectorError.NotOwner -> ConnectionOperationError(NOT_OWNER, retryable = false) + is ConnectorError.UnsupportedRuntime -> ConnectionOperationError(UNSUPPORTED_RUNTIME, retryable = false) + else -> ConnectionOperationError(REQUEST_FAILED) + } + } + private fun matchesSession(snapshot: ConnectionOperationSnapshot): Boolean = !sessionId.isNullOrBlank() && snapshot.sessionId == sessionId @@ -263,7 +276,10 @@ class ChatConnectionOperationDelegate( _state.value = ConnectionOperationUiState() } - private companion object { - const val MAX_SETTLED_IDS = 32 + companion object { + private const val MAX_SETTLED_IDS = 32 + const val REQUEST_FAILED = "request_failed" + const val NOT_OWNER = "not_owner" + const val UNSUPPORTED_RUNTIME = "unsupported_runtime" } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt index 63b3b8e5..19c45c50 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.ConnectionOperationTarget import com.m57.hermescontrol.data.model.ConnectionTargetState +import com.m57.hermescontrol.ui.chat.ChatConnectionOperationDelegate import com.m57.hermescontrol.ui.chat.ConnectionOperationUiState @OptIn(ExperimentalMaterial3Api::class) @@ -224,9 +225,24 @@ internal fun ConnectionSetupContent( } } - state.error?.let { + state.error?.let { error -> Text( - text = stringResource(R.string.connection_setup_request_failed), + text = + stringResource( + when (error.message) { + ChatConnectionOperationDelegate.NOT_OWNER -> { + R.string.session_integrations_err_not_owner + } + + ChatConnectionOperationDelegate.UNSUPPORTED_RUNTIME -> { + R.string.session_integrations_err_unsupported_runtime + } + + else -> { + R.string.connection_setup_request_failed + } + }, + ), color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("connection_setup_error"), ) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt index e1c7771e..d34bc4e7 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.data.ws import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -63,6 +64,39 @@ class ConnectionOperationEventParserTest { assertEquals("", (event as WsEvent.Unknown).raw) } + @Test + fun connectionUpdate_bindsToSessionOwner() { + val event = + EventParser.parseParams( + mapOf( + "type" to "connection.update", + "payload" to + operationPayload(seq = 2L) + + mapOf("owner" to mapOf("type" to "session", "session_id" to "owner-session")), + ), + ) + + assertTrue(event is WsEvent.ConnectionUpdate) + assertEquals("owner-session", (event as WsEvent.ConnectionUpdate).snapshot.sessionId) + } + + @Test + fun connectionUpdate_accountOwnerNeverBindsToSession() { + val event = + EventParser.parseParams( + mapOf( + "type" to "connection.update", + "session_id" to "", + "payload" to + operationPayload(seq = 2L) + + mapOf("owner" to mapOf("type" to "account"), "session_id" to "stale-session"), + ), + ) + + assertTrue(event is WsEvent.ConnectionUpdate) + assertNull((event as WsEvent.ConnectionUpdate).snapshot.sessionId) + } + private fun operationPayload(seq: Long): Map = mapOf( "op_id" to "op-1", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt index bb95cc84..958bff9a 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt @@ -44,7 +44,10 @@ class ConnectorRepositoryTest { val result = repo.listConnectors("sess-abc-123") assertTrue(result is ConnectorListResult.Success) assertEquals(WsMethods.CONNECTORS_LIST, recordedMethod) - assertEquals(mapOf("session_id" to "sess-abc-123"), recordedParams) + assertEquals( + mapOf("owner" to mapOf("type" to "session", "session_id" to "sess-abc-123")), + recordedParams, + ) // Verify no profile or singular slug mutations assertFalse(recordedParams?.containsKey("profile") == true) assertFalse(recordedParams?.containsKey("connector") == true) @@ -80,9 +83,14 @@ class ConnectorRepositoryTest { assertTrue(result is ConnectorConnectResult.Success) assertEquals(WsMethods.CONNECTORS_CONNECT, recordedMethod) - assertEquals("sess-456", recordedParams?.get("session_id")) - assertEquals(listOf("linear", "github_app-1"), recordedParams?.get("connectors")) - assertEquals(true, recordedParams?.get("reconnect")) + assertEquals( + mapOf( + "owner" to mapOf("type" to "session", "session_id" to "sess-456"), + "connectors" to listOf("linear", "github_app-1"), + "reconnect" to true, + ), + recordedParams, + ) // Verify no singular slug key mutation assertFalse(recordedParams?.containsKey("connector") == true) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt index 107e8086..2a4b1315 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt @@ -13,6 +13,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -109,7 +111,8 @@ class ChatConnectionOperationDelegateTest { assertEquals(1, requester.calls.size) val call = requester.calls.single() assertEquals(WsMethods.CONNECTION_RESPOND, call.first) - assertEquals("session-a", call.second["session_id"]) + assertEquals(setOf("owner", "op_id", "result"), call.second.keys) + assertEquals(mapOf("type" to "session", "session_id" to "session-a"), call.second["owner"]) assertEquals("op-a", call.second["op_id"]) @Suppress("UNCHECKED_CAST") val result = call.second["result"] as Map @@ -177,11 +180,63 @@ class ChatConnectionOperationDelegateTest { continueDelegate.continueOperation() assertEquals( - mapOf("settled_by" to "continue"), - continueRequester.calls.single().second["result"], + mapOf( + "owner" to mapOf("type" to "session", "session_id" to "session-a"), + "op_id" to "op-a", + "result" to mapOf("settled_by" to "continue"), + ), + continueRequester.calls.single().second, ) } + @Test + fun wakePayload_sendsSessionOwnerNotTopLevelSessionId() = + runTest { + val requester = RecordingRequester() + val delegate = delegate(requester) + delegate.reset("session-a") + delegate.acceptRequest(snapshot()) + + delegate.wake("op-a") + + val call = requester.calls.single() + assertEquals(WsMethods.CONNECTORS_OPERATION_WAKE, call.first) + assertEquals( + mapOf( + "owner" to mapOf("type" to "session", "session_id" to "session-a"), + "op_id" to "op-a", + ), + call.second, + ) + } + + @Test + fun ownershipAndRuntimeFailures_mapToTypedNonRetryableErrors() = + runTest { + val cases = + listOf( + HermesWsClient.HermesRpcException("x", code = 4001) to + ChatConnectionOperationDelegate.NOT_OWNER, + HermesWsClient.HermesRpcException( + "x", + code = 5033, + data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))), + ) to ChatConnectionOperationDelegate.UNSUPPORTED_RUNTIME, + ) + for ((failure, expected) in cases) { + val delegate = delegate(RecordingRequester(failure = failure)) + delegate.reset("session-a") + delegate.acceptRequest(snapshot()) + + delegate.continueOperation() + + val error = delegate.state.value.error + assertEquals(expected, error?.message) + assertFalse(error?.retryable ?: true) + assertNull(delegate.state.value.pendingAction) + } + } + @Test fun rpcFailureUnlocksWithSanitizedError() = runTest { From ee487e00879ec742dbd15f44962e644ceb42dc04 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:03:53 +0300 Subject: [PATCH 021/107] fix(#1282): require capability consent for plugin updates (#1295) --- .../hermescontrol/data/model/PluginInfo.kt | 20 ++++ .../data/remote/HermesApiService.kt | 3 +- .../hermescontrol/ui/plugins/PluginsScreen.kt | 33 ++++++ .../ui/plugins/PluginsViewModel.kt | 100 ++++++++++++++--- app/src/main/res/values/strings.xml | 4 + .../ui/plugins/PluginsViewModelTest.kt | 103 ++++++++++++++++++ 6 files changed, 247 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt index 1e00957e..b6f47a46 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt @@ -81,6 +81,26 @@ data class PluginsHubResponse( val providers: PluginsHubProviders? = null, ) +@Serializable +data class PluginUpdateRequest( + @SerialName("accept_capabilities") val acceptCapabilities: Boolean, +) + +@Serializable +data class PluginUpdateResult( + val ok: Boolean = false, + val unchanged: Boolean = false, + @SerialName("consent_required") val consentRequired: Boolean = false, + val delta: Map>? = null, + @SerialName("delta_lines") val deltaLines: List? = null, + val error: String? = null, +) { + val capabilityDelta: List + get() = + deltaLines?.takeIf { it.isNotEmpty() } + ?: delta.orEmpty().map { (surface, added) -> "$surface: ${added.joinToString() }" } +} + @Serializable data class AgentPluginInstallBody( val identifier: String = "", 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 55e6cddf..c51156a9 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 @@ -670,7 +670,8 @@ interface HermesApiService : KanbanApiService { @POST("api/dashboard/agent-plugins/{name}/update") suspend fun updatePlugin( @Path("name", encoded = true) name: String, - ): Response + @Body body: com.m57.hermescontrol.data.model.PluginUpdateRequest, + ): Response @POST("api/dashboard/agent-plugins/{name}/enable") suspend fun enablePlugin( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index 5cbd407a..d691fc92 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.InstallDesktop @@ -120,6 +122,37 @@ fun PluginsScreen( ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) + state.updateConsent?.let { consent -> + AlertDialog( + onDismissRequest = viewModel::cancelPluginUpdate, + title = { Text(stringResource(R.string.plugins_update_consent_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(stringResource(R.string.plugins_update_consent_text, consent.name)) + val lines = consent.result.capabilityDelta + if (lines.isEmpty()) { + Text(consent.result.error ?: stringResource(R.string.plugins_update_consent_missing)) + } else { + lines.forEach { Text(it) } + } + } + }, + confirmButton = { + TextButton(onClick = viewModel::confirmPluginUpdate, enabled = state.rowBusy == null) { + Text(stringResource(R.string.plugins_update_accept)) + } + }, + dismissButton = { + TextButton(onClick = viewModel::cancelPluginUpdate) { + Text(stringResource(R.string.common_cancel)) + } + }, + ) + } + // Remove confirmation dialog if (state.removeConfirmPlugin != null) { AlertDialog( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index 10d50bde..a05daba1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -9,6 +9,8 @@ import com.m57.hermescontrol.data.model.AgentPluginInstallBody import com.m57.hermescontrol.data.model.PluginCatalogEntry import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginProvidersPutRequest +import com.m57.hermescontrol.data.model.PluginUpdateRequest +import com.m57.hermescontrol.data.model.PluginUpdateResult import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.ProviderOption import com.m57.hermescontrol.data.remote.ApiClient @@ -33,6 +35,12 @@ enum class PluginsTab { CATALOG, } +data class PluginUpdateConsent( + val name: String, + val result: PluginUpdateResult, + val scope: DataScope?, +) + data class PluginsUiState( val selectedTab: PluginsTab = PluginsTab.INSTALLED, val isLoading: Boolean = false, @@ -55,6 +63,7 @@ data class PluginsUiState( val rowBusy: String? = null, // Confirm remove dialog val removeConfirmPlugin: String? = null, + val updateConsent: PluginUpdateConsent? = null, // Rescan val rescanBusy: Boolean = false, // Catalog state @@ -99,6 +108,7 @@ class PluginsViewModel( providerBusy = false, rowBusy = null, removeConfirmPlugin = null, + updateConsent = null, rescanBusy = false, ) } @@ -450,23 +460,83 @@ class PluginsViewModel( } fun updatePlugin(name: String) { - viewModelScope.launch { - setRowBusy(name) - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.updatePlugin(name) } - } - when (result) { - is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Plugin updated successfully") } - clearRowBusy(name) - loadPlugins(forceRefresh = true) - } + if (_uiState.value.updateConsent != null) return + performPluginUpdate(name, acceptCapabilities = false) + } - is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") } - clearRowBusy(name) + fun cancelPluginUpdate() { + _uiState.update { it.copy(updateConsent = null) } + } + + fun confirmPluginUpdate() { + val consent = _uiState.value.updateConsent ?: return + if (_uiState.value.rowBusy != null) return + cancelPluginUpdate() + if (consent.scope != runCatching { AuthManager.currentDataScope() }.getOrNull()) return + performPluginUpdate(consent.name, acceptCapabilities = true) + } + + private fun performPluginUpdate( + name: String, + acceptCapabilities: Boolean, + ) { + if (_uiState.value.rowBusy != null) return + val generation = scopeGeneration + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + val api = ApiClient.hermesApi + setRowBusy(name) + _uiState.update { it.copy(toastMessage = null) } + viewModelScope.launch { + try { + val result = safeApiCall { api.updatePlugin(name, PluginUpdateRequest(acceptCapabilities)) } + if (generation != scopeGeneration || + requestScope != runCatching { AuthManager.currentDataScope() }.getOrNull() + ) { + return@launch } + when (result) { + is NetworkResult.Success -> { + val update = result.data + // #1282: HTTP success is not update success; consent must be explicit. + when { + update.consentRequired -> { + _uiState.update { + it.copy(updateConsent = PluginUpdateConsent(name, update, requestScope)) + } + } + + !update.ok -> { + _uiState.update { + it.copy( + toastMessage = "Failed to update plugin: ${update.error ?: "Update refused"}", + ) + } + } + + else -> { + _uiState.update { + it.copy( + toastMessage = + if (update.unchanged) { + "Plugin is already up to date" + } else { + "Plugin updated successfully" + }, + ) + } + loadPlugins(forceRefresh = true) + } + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") + } + } + } + } finally { + if (generation == scopeGeneration) clearRowBusy(name) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 968d3120..df298c62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1779,4 +1779,8 @@ Hide header values Use unique HTTP header names and single-line ASCII values. Authorization, Cookie, Host, and connection headers are managed by the app. Could not save custom headers. Try again. + Review new capabilities + Updating %1$s requires the additional capabilities below. Nothing has been installed yet. Accept these capabilities and update? + The server did not provide capability details. + Accept and update diff --git a/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt index 7c8c759f..92c981d8 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt @@ -5,6 +5,8 @@ import com.m57.hermescontrol.data.model.PluginCatalogCapabilities import com.m57.hermescontrol.data.model.PluginCatalogEntry import com.m57.hermescontrol.data.model.PluginCatalogResponse import com.m57.hermescontrol.data.model.PluginInfo +import com.m57.hermescontrol.data.model.PluginUpdateRequest +import com.m57.hermescontrol.data.model.PluginUpdateResult import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService @@ -72,6 +74,107 @@ class PluginsViewModelTest { unmockkAll() } + @Test + fun `update waits for consent and only accepts on explicit confirmation`() { + coEvery { mockApi.updatePlugin("snyk", PluginUpdateRequest(false)) } returns + Response.success(PluginUpdateResult(consentRequired = true, deltaLines = listOf("tools: scan"))) + coEvery { mockApi.updatePlugin("snyk", PluginUpdateRequest(true)) } returns + Response.success(PluginUpdateResult(ok = true)) + coEvery { mockApi.getPlugins() } returns Response.success(PluginsHubResponse(emptyList())) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + assertEquals( + listOf("tools: scan"), + viewModel.uiState.value.updateConsent + ?.result + ?.capabilityDelta, + ) + assertNull(viewModel.uiState.value.toastMessage) + assertNull(viewModel.uiState.value.rowBusy) + coVerify(exactly = 0) { mockApi.updatePlugin(any(), PluginUpdateRequest(true)) } + coVerify(exactly = 0) { mockApi.getPlugins() } + viewModel.confirmPluginUpdate() + viewModel.confirmPluginUpdate() + testDispatcher.scheduler.advanceUntilIdle() + assertNull(viewModel.uiState.value.updateConsent) + assertEquals("Plugin updated successfully", viewModel.uiState.value.toastMessage) + coVerify(exactly = 1) { mockApi.updatePlugin("snyk", PluginUpdateRequest(false)) } + coVerify(exactly = 1) { mockApi.updatePlugin("snyk", PluginUpdateRequest(true)) } + coVerify(exactly = 1) { mockApi.getPlugins() } + } + + @Test + fun `cancel consent never retries the update`() { + coEvery { mockApi.updatePlugin(any(), any()) } returns + Response.success(PluginUpdateResult(consentRequired = true)) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + viewModel.cancelPluginUpdate() + viewModel.confirmPluginUpdate() + testDispatcher.scheduler.advanceUntilIdle() + assertNull(viewModel.uiState.value.updateConsent) + assertNull(viewModel.uiState.value.toastMessage) + coVerify(exactly = 1) { mockApi.updatePlugin(any(), any()) } + } + + @Test + fun `ok false is a failure even with HTTP 200 and unchanged true`() { + coEvery { mockApi.updatePlugin(any(), any()) } returns + Response.success(PluginUpdateResult(unchanged = true, error = "Update refused")) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("Failed to update plugin: Update refused", viewModel.uiState.value.toastMessage) + assertNull(viewModel.uiState.value.rowBusy) + coVerify(exactly = 0) { mockApi.getPlugins() } + } + + @Test + fun `unchanged success reports already up to date`() { + coEvery { mockApi.updatePlugin(any(), any()) } returns + Response.success(PluginUpdateResult(ok = true, unchanged = true)) + coEvery { mockApi.getPlugins() } returns Response.success(PluginsHubResponse(emptyList())) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("Plugin is already up to date", viewModel.uiState.value.toastMessage) + assertNull(viewModel.uiState.value.rowBusy) + } + + @Test + fun `scope switch discards late update consent`() { + val reply = CompletableDeferred>() + coEvery { mockApi.updatePlugin(any(), any()) } coAnswers { reply.await() } + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.runCurrent() + viewModel.clearScopeOwnedState() + reply.complete(Response.success(PluginUpdateResult(consentRequired = true))) + testDispatcher.scheduler.advanceUntilIdle() + viewModel.confirmPluginUpdate() + assertNull(viewModel.uiState.value.updateConsent) + assertNull(viewModel.uiState.value.rowBusy) + assertNull(viewModel.uiState.value.toastMessage) + coVerify(exactly = 1) { mockApi.updatePlugin(any(), any()) } + } + + @Test + fun `HTTP update failure releases busy state`() { + coEvery { mockApi.updatePlugin(any(), any()) } returns Response.error(503, "offline".toResponseBody()) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + assertTrue( + viewModel.uiState.value.toastMessage!! + .contains("Failed to update"), + ) + assertNull(viewModel.uiState.value.rowBusy) + coVerify(exactly = 0) { mockApi.getPlugins() } + } + @Test fun `switch tab to CATALOG automatically triggers catalog load`() { coEvery { mockApi.getPluginCatalog() } returns From c34de55dd988ece804c5fb74fec5ce1ebcfcd59c Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:44:41 +0300 Subject: [PATCH 022/107] fix(#1283): respect plugin ownership of MCP servers (#1296) --- .../ui/mcp/McpOwnershipCardTest.kt | 77 +++++++ .../m57/hermescontrol/data/model/McpServer.kt | 7 +- .../ui/mcp/McpServersViewModel.kt | 58 ++++-- .../ui/mcp/components/ServerCard.kt | 29 ++- app/src/main/res/values/strings.xml | 2 + .../hermescontrol/ui/mcp/McpOwnershipTest.kt | 193 ++++++++++++++++++ 6 files changed, 341 insertions(+), 25 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/mcp/McpOwnershipCardTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/mcp/McpOwnershipCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/mcp/McpOwnershipCardTest.kt new file mode 100644 index 00000000..ddac625e --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/mcp/McpOwnershipCardTest.kt @@ -0,0 +1,77 @@ +package com.m57.hermescontrol.ui.mcp + +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.isToggleable +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.McpServer +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.mcp.components.ServerCard +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class McpOwnershipCardTest { + @get:Rule val composeRule = createComposeRule() + + @Test + fun pluginRowExplainsOwnershipAndDisablesMutations() { + showServer("plugin", "security") + composeRule.onNodeWithText("From plugin security · Read-only", useUnmergedTree = true).assertExists() + composeRule.onNode(isToggleable()).assertIsNotEnabled() + composeRule.onNodeWithContentDescription("Delete").assertIsNotEnabled() + composeRule.onNodeWithText("Authorize").assertIsNotEnabled() + composeRule.onNodeWithText("Test").assertIsEnabled() + composeRule.onNodeWithText("Env vars").performClick() + composeRule.onNodeWithContentDescription("Remove").assertIsNotEnabled() + composeRule.onNodeWithText("Add env var").assertIsNotEnabled() + composeRule.onNodeWithText("KEY", useUnmergedTree = true).assertExists() + } + + @Test + fun configRowKeepsMutationsEnabled() { + showServer("config", null) + composeRule.onNode(isToggleable()).assertIsEnabled() + composeRule.onNodeWithContentDescription("Delete").assertIsEnabled() + composeRule.onNodeWithText("Authorize").assertIsEnabled() + composeRule.onNodeWithText("Env vars").performClick() + composeRule.onNodeWithContentDescription("Remove").assertIsEnabled() + composeRule.onNodeWithText("Add env var").assertIsEnabled() + } + + private fun showServer( + source: String, + plugin: String?, + ) { + val server = + McpServer( + name = "scanner", + enabled = true, + source = source, + plugin = plugin, + auth = "oauth", + env = mapOf("KEY" to "test-value"), + ) + val viewModel = McpServersViewModel() + composeRule.setContent { + HermesControlTheme { + ServerCard( + server = server, + state = McpServersUiState(servers = listOf(server)), + viewModel = viewModel, + spacing = LocalSpacing.current, + onClick = {}, + onOpenBrowser = {}, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt index 69ba7c59..9b671430 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt @@ -20,7 +20,12 @@ data class McpServer( val error: String? = null, val auth: String? = null, val tools: List? = null, -) + val source: String = "config", + val plugin: String? = null, +) { + // #1283: plugin-provided servers are managed by their owning plugin. + val isPluginOwned: Boolean get() = source == "plugin" +} @Serializable data class McpServerTestResponse( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index ec585b4d..97ac71bf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -12,11 +12,13 @@ import com.m57.hermescontrol.data.model.McpServerTestResponse import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -66,8 +68,9 @@ data class McpServersUiState( val activeOAuthFlow: McpOAuthFlowResponse? = null, ) -class McpServersViewModel : - ViewModel(), +class McpServersViewModel( + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(McpServersUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -131,6 +134,7 @@ class McpServersViewModel : // ── Server toggle ──────────────────────────────────────── fun toggleServer(server: McpServer) { + if (server.isPluginOwned || isPluginOwned(server.name)) return val originalEnabled = server.enabled val targetEnabled = !originalEnabled @@ -145,7 +149,7 @@ class McpServersViewModel : viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.toggleMcpServer( server.name, @@ -163,7 +167,7 @@ class McpServersViewModel : } is NetworkResult.Failure -> { - revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}") + revertToggle(server.name, originalEnabled, mutationError(result.error, "Failed to toggle server")) } } } @@ -178,7 +182,7 @@ class McpServersViewModel : ) } val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.testMcpServer(name) } } when (result) { @@ -234,7 +238,7 @@ class McpServersViewModel : val testJobs = enabledServers.map { server -> - async(Dispatchers.IO) { + async(ioDispatcher) { val res = safeApiCall { ApiClient.hermesApi.testMcpServer(server.name) } server.name to res } @@ -271,9 +275,10 @@ class McpServersViewModel : } fun deleteServer(name: String) { + if (isPluginOwned(name)) return viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.deleteMcpServer(name) } } when (result) { @@ -283,7 +288,7 @@ class McpServersViewModel : } is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to delete server: ${result.error.message}") } + _uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to delete server")) } } } } @@ -311,7 +316,7 @@ class McpServersViewModel : viewModelScope.launch { val importJobs = requests.map { req -> - async(Dispatchers.IO) { + async(ioDispatcher) { val result = safeApiCall { ApiClient.hermesApi.addMcpServer(req) } req to result } @@ -387,7 +392,7 @@ class McpServersViewModel : viewModelScope.launch { val request = AddServerRequestBuilder.build(state) val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.addMcpServer(request) } } when (result) { @@ -412,7 +417,7 @@ class McpServersViewModel : _uiState.update { it.copy( addingServer = false, - toastMessage = "Failed to add server: ${result.error.message}", + toastMessage = mutationError(result.error, "Failed to add server"), ) } } @@ -423,6 +428,7 @@ class McpServersViewModel : // ── Env var editing ────────────────────────────────────── fun startEditingEnv(server: McpServer) { + if (server.isPluginOwned || isPluginOwned(server.name)) return _uiState.update { it.copy(editingEnvFor = server.name, envKeyInput = "", envValueInput = "") } } @@ -439,6 +445,7 @@ class McpServersViewModel : } fun addEnvVar(serverName: String) { + if (isPluginOwned(serverName)) return val state = _uiState.value val key = state.envKeyInput.trim() val value = state.envValueInput.trim() @@ -450,7 +457,7 @@ class McpServersViewModel : val existingEnv = state.servers.find { it.name == serverName }?.env ?: emptyMap() val updatedEnv = existingEnv + (key to value) val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) } } when (result) { @@ -466,7 +473,7 @@ class McpServersViewModel : } is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to add env var: ${result.error.message}") } + _uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to add env var")) } } } } @@ -476,12 +483,13 @@ class McpServersViewModel : serverName: String, key: String, ) { + if (isPluginOwned(serverName)) return viewModelScope.launch { val state = _uiState.value val existingEnv = state.servers.find { it.name == serverName }?.env ?: emptyMap() val updatedEnv = existingEnv - key val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) } } when (result) { @@ -491,7 +499,7 @@ class McpServersViewModel : } is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to remove env var: ${result.error.message}") } + _uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to remove env var")) } } } } @@ -503,7 +511,7 @@ class McpServersViewModel : _uiState.update { it.copy(catalogLoading = true, catalogError = null) } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getMcpCatalog() } } when (result) { @@ -542,7 +550,7 @@ class McpServersViewModel : env = state.catalogInstallEnv.ifEmpty { null }, ) val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.installMcpCatalogEntry(request) } } when (result) { @@ -584,11 +592,12 @@ class McpServersViewModel : server: McpServer, onOpenBrowser: (String) -> Unit, ) { + if (server.isPluginOwned || isPluginOwned(server.name)) return oauthPollJob?.cancel() _uiState.update { it.copy(toastMessage = "Starting OAuth authorization…") } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.authMcpServer(server.name) } } when (result) { @@ -608,7 +617,7 @@ class McpServersViewModel : } is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to start OAuth: ${result.error.message}") } + _uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to start OAuth")) } } } } @@ -623,7 +632,7 @@ class McpServersViewModel : while (polling) { kotlinx.coroutines.delay(2000) val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getMcpOAuthFlowStatus(flowId) } } when (result) { @@ -688,6 +697,15 @@ class McpServersViewModel : // ── Helpers ────────────────────────────────────────────── + // #1283: re-check current ownership so stale row callbacks cannot mutate a plugin server. + private fun isPluginOwned(name: String): Boolean = + _uiState.value.servers.any { it.name == name && it.isPluginOwned } + + private fun mutationError( + error: NetworkError, + prefix: String, + ): String = if (error is NetworkError.Http && error.code == 409) error.message else "$prefix: ${error.message}" + private fun revertToggle( name: String, originalEnabled: Boolean, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt index 155f6c32..b5b22347 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt @@ -139,6 +139,16 @@ fun ServerCard( color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (server.isPluginOwned) { + Text( + text = + server.plugin?.let { stringResource(R.string.mcp_servers_from_plugin, it) } + ?: stringResource(R.string.mcp_servers_plugin_managed), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Tool count & schema token overhead badge (issue #1029) val toolInfos = testResult?.tools val toolCount = toolInfos?.size ?: server.tools?.size @@ -173,6 +183,7 @@ fun ServerCard( } Switch( checked = server.enabled, + enabled = !server.isPluginOwned, onCheckedChange = { viewModel.toggleServer(server) }, ) } @@ -218,6 +229,7 @@ fun ServerCard( if (server.auth == "oauth") { FilledTonalButton( onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) }, + enabled = !server.isPluginOwned, modifier = Modifier.weight(1.2f), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), ) { @@ -245,7 +257,10 @@ fun ServerCard( Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1) } } - IconButton(onClick = { viewModel.deleteServer(server.name) }) { + IconButton( + onClick = { viewModel.deleteServer(server.name) }, + enabled = !server.isPluginOwned, + ) { Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete)) } } @@ -275,7 +290,7 @@ fun EnvVarSection( spacing: Spacing, modifier: Modifier = Modifier, ) { - val isEditing = state.editingEnvFor == server.name + val isEditing = !server.isPluginOwned && state.editingEnvFor == server.name val env = server.env ?: emptyMap() Column(modifier = modifier) { @@ -300,7 +315,10 @@ fun EnvVarSection( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) { + IconButton( + onClick = { viewModel.removeEnvVar(server.name, key) }, + enabled = !server.isPluginOwned, + ) { Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp)) } } @@ -342,7 +360,10 @@ fun EnvVarSection( } } } else { - TextButton(onClick = { viewModel.startEditingEnv(server) }) { + TextButton( + onClick = { viewModel.startEditingEnv(server) }, + enabled = !server.isPluginOwned, + ) { Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp)) Spacer(modifier = Modifier.width(spacing.xs)) Text(stringResource(R.string.mcp_servers_env_add)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index df298c62..a6ca654c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1119,6 +1119,8 @@ Transport: %1$s + From plugin %1$s · Read-only + Plugin-managed · Read-only Command: Test Test All diff --git a/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt new file mode 100644 index 00000000..81c7c7a1 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt @@ -0,0 +1,193 @@ +package com.m57.hermescontrol.ui.mcp + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.McpServer +import com.m57.hermescontrol.data.model.McpServerTestResponse +import com.m57.hermescontrol.data.model.McpServersResponse +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.remote.OkHttpProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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 McpOwnershipTest { + private val dispatcher = StandardTestDispatcher() + private lateinit var api: HermesApiService + private val owned = McpServer(name = "scanner", enabled = true, source = "plugin", plugin = "security") + private val conflict = "Server 'scanner' is provided by plugin 'security' and cannot be modified" + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + api = mockk() + mockkObject(ApiClient, AuthManager) + every { ApiClient.hermesApi } returns api + every { AuthManager.currentDataScope() } throws IllegalStateException("No test scope") + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `ownership decodes and legacy servers remain editable`() { + val json = OkHttpProvider.json + val plugin = + json.decodeFromString( + """{"name":"scanner","enabled":true,"source":"plugin","plugin":"security"}""", + ) + assertEquals(owned, plugin) + assertTrue(plugin.isPluginOwned) + assertTrue(plugin.copy(plugin = null).isPluginOwned) + val legacy = json.decodeFromString("""{"name":"local","enabled":true}""") + assertFalse(legacy.isPluginOwned) + assertNull(legacy.plugin) + val config = + json.decodeFromString( + """{"name":"local","enabled":true,"source":"config","plugin":null}""", + ) + assertFalse(config.isPluginOwned) + } + + @Test + fun `plugin mutations are blocked even for stale callbacks but testing remains available`() { + val vm = loaded(owned) + val stale = owned.copy(source = "config", plugin = null) + vm.toggleServer(stale) + vm.deleteServer(owned.name) + vm.startEditingEnv(stale) + vm.updateEnvKey("KEY") + vm.updateEnvValue("value") + vm.addEnvVar(owned.name) + vm.removeEnvVar(owned.name, "KEY") + vm.startMcpOAuthFlow(stale) { error("Must not open browser") } + dispatcher.scheduler.advanceUntilIdle() + assertTrue( + vm.uiState.value.servers + .single() + .enabled, + ) + assertNull(vm.uiState.value.editingEnvFor) + coVerify(exactly = 0) { api.toggleMcpServer(any(), any()) } + coVerify(exactly = 0) { api.deleteMcpServer(any()) } + coVerify(exactly = 0) { api.updateMcpServer(any(), any()) } + coVerify(exactly = 0) { api.authMcpServer(any()) } + coEvery { api.testMcpServer(owned.name) } returns Response.success(McpServerTestResponse(ok = true)) + vm.testServer(owned.name) + dispatcher.scheduler.advanceUntilIdle() + assertTrue( + vm.uiState.value.serverTestResults + .getValue(owned.name) + .ok, + ) + } + + @Test + fun `config server toggle and environment editing remain available`() { + val config = owned.copy(source = "config", plugin = null) + val vm = loaded(config) + coEvery { api.toggleMcpServer(config.name, any()) } returns Response.success(Unit) + vm.toggleServer(config) + dispatcher.scheduler.advanceUntilIdle() + assertFalse( + vm.uiState.value.servers + .single() + .enabled, + ) + vm.startEditingEnv(config) + assertEquals(config.name, vm.uiState.value.editingEnvFor) + coEvery { api.updateMcpServer(config.name, any()) } returns Response.success(config) + vm.updateEnvKey("KEY") + vm.updateEnvValue("value") + vm.addEnvVar(config.name) + dispatcher.scheduler.advanceUntilIdle() + coVerify { api.updateMcpServer(config.name, mapOf("env" to mapOf("KEY" to "value"))) } + vm.removeEnvVar(config.name, "KEY") + dispatcher.scheduler.advanceUntilIdle() + coVerify { api.updateMcpServer(config.name, mapOf("env" to emptyMap())) } + coEvery { api.deleteMcpServer(config.name) } returns Response.success(Unit) + vm.deleteServer(config.name) + dispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 1) { api.deleteMcpServer(config.name) } + } + + @Test + fun `stale ownership conflicts surface detail verbatim and revert toggle`() { + val config = owned.copy(source = "config", plugin = null) + val vm = loaded(config) + coEvery { api.toggleMcpServer(any(), any()) } returns conflictResponse() + vm.toggleServer(config) + dispatcher.scheduler.advanceUntilIdle() + assertTrue( + vm.uiState.value.servers + .single() + .enabled, + ) + assertEquals(conflict, vm.uiState.value.toastMessage) + coEvery { api.deleteMcpServer(any()) } returns conflictResponse() + vm.deleteServer(config.name) + dispatcher.scheduler.advanceUntilIdle() + assertEquals(conflict, vm.uiState.value.toastMessage) + coEvery { api.updateMcpServer(any(), any()) } coAnswers { conflictResponse() } + vm.updateEnvKey("KEY") + vm.addEnvVar(config.name) + dispatcher.scheduler.advanceUntilIdle() + assertEquals(conflict, vm.uiState.value.toastMessage) + vm.clearToast() + vm.removeEnvVar(config.name, "KEY") + dispatcher.scheduler.advanceUntilIdle() + assertEquals(conflict, vm.uiState.value.toastMessage) + coEvery { api.authMcpServer(any()) } returns conflictResponse() + vm.clearToast() + vm.startMcpOAuthFlow(config) { error("Must not open browser") } + dispatcher.scheduler.advanceUntilIdle() + assertEquals(conflict, vm.uiState.value.toastMessage) + } + + @Test + fun `add collision surfaces plugin conflict verbatim`() { + coEvery { api.addMcpServer(any()) } returns conflictResponse() + val vm = McpServersViewModel(ioDispatcher = dispatcher) + vm.toggleAddForm() + vm.updateAddServerName(owned.name) + vm.updateAddServerUrl("https://example.com/mcp") + vm.submitAddServer() + dispatcher.scheduler.advanceUntilIdle() + assertEquals(conflict, vm.uiState.value.toastMessage) + assertFalse(vm.uiState.value.addingServer) + assertTrue(vm.uiState.value.showAddForm) + assertEquals(owned.name, vm.uiState.value.addServerName) + } + + private fun loaded(server: McpServer): McpServersViewModel { + coEvery { api.getMcpServers() } returns Response.success(McpServersResponse(listOf(server))) + return McpServersViewModel(ioDispatcher = dispatcher).also { + it.loadServers() + dispatcher.scheduler.advanceUntilIdle() + assertEquals(listOf(server), it.uiState.value.servers) + } + } + + private fun conflictResponse(): Response = Response.error(409, """{"detail":"$conflict"}""".toResponseBody()) +} From 45de69a271b68734062f6f19fb6b8b22dff8f0e5 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:05:32 +0300 Subject: [PATCH 023/107] fix(#1284): honor display_commentary/display_reasoning in history (#1297) --- .../data/model/SessionMessagesResponse.kt | 47 +++++++ .../ui/chat/ChatServerMessageMapper.kt | 17 ++- .../ui/chat/ChatHistoryCommentaryTest.kt | 118 ++++++++++++++++++ 3 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatHistoryCommentaryTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt index 9ea2521a..a5a5e1a1 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.data.model import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.doubleOrNull @@ -59,6 +60,17 @@ data class SessionMessage( val display_metadata: JsonElement? = null, /** Token count recorded by the backend. */ val token_count: JsonElement? = null, + /** + * Public interim commentary projected by the backend for assistant rows + * (agent/history_commentary.py, issue #1284). Rendered as assistant text + * ahead of the reply. Absent on older gateways. + */ + val display_commentary: JsonElement? = null, + /** + * [reasoning] with the flattened commentary removed. When present (even + * empty) it wins over the raw reasoning so commentary is not shown twice. + */ + val display_reasoning: JsonElement? = null, ) { val timestampText: String? get() = (timestamp as? JsonPrimitive)?.content @@ -107,6 +119,41 @@ data class SessionMessage( else -> r.toString() } + /** True when the backend projected reasoning; it is then authoritative, even if empty. */ + val hasDisplayReasoning: Boolean + get() = (display_reasoning as? JsonPrimitive)?.isString == true + + /** [display_reasoning] when projected, else raw reasoning (older gateways). */ + val displayReasoningText: String + get() = (display_reasoning as? JsonPrimitive)?.takeIf { it.isString }?.content ?: reasoningText + + /** Nonblank public commentary items, in order. */ + val displayCommentary: List + get() = + (display_commentary as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content?.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + /** + * Visible assistant text: interim commentary, then the reply. Commentary that + * merely repeats the reply (some providers persist it as content) renders once. + */ + val visibleText: String + get() { + val reply = displayContentText ?: contentText + val commentary = displayCommentary + if (commentary.isEmpty()) return reply + val joined = commentary.joinToString("\n\n") + if (reply.isBlank()) return joined + + fun normalized(value: String) = value.replace(WHITESPACE, " ").trim() + if (normalized(joined) == normalized(reply)) return reply + return "$joined\n\n$reply" + } + val toolCallId: String get() = tool_call_id.orEmpty() } + +private val WHITESPACE = Regex("\\s+") 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 79a9972b..9bc3a25b 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 @@ -179,13 +179,18 @@ internal fun mapServerMessages( ?: existingById[restId]?.timestamp ?: System.currentTimeMillis() - val rawContent = msg.displayContentText ?: msg.contentText + val rawContent = msg.visibleText + // #1284: projected reasoning already excludes public commentary; it is authoritative. val rowReasoning = - msg.reasoningText.ifBlank { - if (role == MessageRole.ASSISTANT) { - reasoningSources[index]?.reasoningText.orEmpty() - } else { - existingById[restId]?.reasoningText.orEmpty() + if (msg.hasDisplayReasoning) { + msg.displayReasoningText + } else { + msg.reasoningText.ifBlank { + if (role == MessageRole.ASSISTANT) { + reasoningSources[index]?.reasoningText.orEmpty() + } else { + existingById[restId]?.reasoningText.orEmpty() + } } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatHistoryCommentaryTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatHistoryCommentaryTest.kt new file mode 100644 index 00000000..c51fcc5c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatHistoryCommentaryTest.kt @@ -0,0 +1,118 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.SessionMessage +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** Issue #1284: history rows carry display_commentary / display_reasoning projections. */ +class ChatHistoryCommentaryTest { + private fun row( + content: String, + reasoning: String? = null, + commentary: List? = null, + displayReasoning: String? = null, + id: Int = 7, + ) = SessionMessage( + id = id, + role = "assistant", + content = JsonPrimitive(content), + reasoning = reasoning?.let { JsonPrimitive(it) }, + display_commentary = commentary?.let { list -> JsonArray(list.map { JsonPrimitive(it) }) }, + display_reasoning = displayReasoning?.let { JsonPrimitive(it) as JsonElement }, + ) + + private fun map(vararg rows: SessionMessage) = mapServerMessages("s", rows.toList(), 0, true, emptyList()) + + private fun countOf( + haystack: String, + needle: String, + ) = haystack.windowed(needle.length).count { it == needle } + + @Test + fun commentaryRendersOnceBeforeReplyAndLeavesReasoning() { + val commentary = "I will inspect the files." + val message = + map( + row( + content = "Done: two files changed.", + reasoning = "Private plan.\n\n$commentary", + commentary = listOf(commentary), + displayReasoning = "Private plan.", + ), + ).single() + + assertEquals("$commentary\n\nDone: two files changed.", message.content) + assertEquals("Private plan.", message.reasoningText) + assertEquals(1, countOf(message.content + message.reasoningText, commentary)) + } + + @Test + fun emptyDisplayReasoningWinsOverRawReasoning() { + val commentary = "Checking." + val message = + map(row(content = "", reasoning = commentary, commentary = listOf(commentary), displayReasoning = "")) + .single() + + assertEquals(commentary, message.content) + assertEquals("", message.reasoningText) + } + + @Test + fun commentaryEqualToReplyIsNotDuplicated() { + val text = "Looking at it now." + val message = map(row(content = text, commentary = listOf(text), displayReasoning = "")).single() + + assertEquals(text, message.content) + } + + @Test + fun disabledCommentaryKeepsItOutOfTranscriptAndReasoning() { + // Backend policy off: empty list, display_reasoning still strips the raw copy. + val message = + map( + row( + content = "Answer", + reasoning = "Plan.\nSecret interim", + commentary = emptyList(), + displayReasoning = "Plan.", + ), + ).single() + + assertEquals("Answer", message.content) + assertEquals("Plan.", message.reasoningText) + assertFalse(message.content.contains("Secret interim")) + } + + @Test + fun olderGatewayFallsBackToRawReasoning() { + val message = map(row(content = "Answer", reasoning = "Raw trace")).single() + + assertEquals("Answer", message.content) + assertEquals("Raw trace", message.reasoningText) + } + + @Test + fun liveSealedCommentaryBubbleIsCoveredByRestRowNotDuplicated() { + val commentary = "I will inspect the files." + val liveOrphan = ChatMessage(id = "live-orphan", role = MessageRole.ASSISTANT, content = commentary) + val rest = + map(row(content = "", reasoning = commentary, commentary = listOf(commentary), displayReasoning = "")) + + val merged = mergeTranscriptWithLive(rest, listOf(liveOrphan), preserveLiveIds = true) + + assertEquals(1, merged.count { it.content.contains(commentary) }) + assertEquals("", merged.single().reasoningText) + } + + @Test + fun multipleCommentaryItemsKeepOrderAndSkipBlanks() { + val message = + map(row(content = "Final", commentary = listOf("First.", " ", "Second."), displayReasoning = "")).single() + + assertEquals("First.\n\nSecond.\n\nFinal", message.content) + } +} From 7ae97a10991d8c5fe006f1df4ad2aca67c6bbfde Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:42:01 +0300 Subject: [PATCH 024/107] fix(#1286): handle corrupt session storage in session lists (#1298) --- .../data/model/SessionListResponse.kt | 1 + .../ui/sessions/SessionsScreen.kt | 23 +++++++ .../ui/sessions/SessionsViewModel.kt | 31 +++++++-- app/src/main/res/values/strings.xml | 1 + .../ui/sessions/SessionsViewModelTest.kt | 66 +++++++++++++++++++ 5 files changed, 118 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index bbffae54..8b391ba0 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -63,6 +63,7 @@ data class SessionListResponse( val total: Int = 0, val limit: Int = 0, val offset: Int = 0, + val storage: Map = emptyMap(), ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 3f00fcb2..5ea87d77 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -711,6 +711,17 @@ fun SessionsScreen( ) } + state.sessions.isEmpty() && state.corruptStorageProfiles.isNotEmpty() -> { + ErrorState( + message = + stringResource( + R.string.sessions_storage_corrupt, + state.corruptStorageProfiles.sorted().joinToString(", "), + ), + onRetry = { viewModel.loadSessions(forceRefresh = true) }, + ) + } + state.sessions.isEmpty() -> { EmptyState( title = @@ -752,6 +763,18 @@ fun SessionsScreen( else -> { Column(modifier = Modifier.fillMaxSize()) { + if (state.corruptStorageProfiles.isNotEmpty()) { + Text( + text = + stringResource( + R.string.sessions_storage_corrupt, + state.corruptStorageProfiles.sorted().joinToString(", "), + ), + color = statusColors.warning, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(spacing.md), + ) + } // ── Stats row ─────────────────────────────────────── SessionsStatsRow( total = state.total, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 30c20414..87363b65 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -81,6 +81,7 @@ data class SessionsUiState( val total: Int = 0, val hasMore: Boolean = false, val errorMessage: String? = null, + val corruptStorageProfiles: Set = emptySet(), val stats: SessionStats = SessionStats(), val isLoadingStats: Boolean = false, val statsError: String? = null, @@ -197,6 +198,7 @@ class SessionsViewModel( val (requestScope, capturedSection) = scopeAndSection val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() if (requestGeneration == generation && currentScope == requestScope) { + if (handleStorageHealth(data)) return@refreshOnChange val localKey = "${capturedSection.name}:${capturedSection.source}:${capturedSection.excludeSources}" val inMemoryKey = requestScope?.inMemoryKey(localKey) val persistentKey = requestScope?.persistentKey(localKey) @@ -244,6 +246,7 @@ class SessionsViewModel( selectedIds = emptySet(), isSelecting = false, errorMessage = null, + corruptStorageProfiles = emptySet(), searchResults = emptyList(), searchError = null, searchLoadMoreError = null, @@ -342,6 +345,7 @@ class SessionsViewModel( total = 0, hasMore = false, errorMessage = null, + corruptStorageProfiles = emptySet(), isSelecting = false, selectedIds = emptySet(), isSearching = false, @@ -352,6 +356,26 @@ class SessionsViewModel( if (query.isBlank()) loadSessions() else setSearchQuery(query) } + // #1286: a partial/empty damaged-store page is not an authoritative replacement. + private fun handleStorageHealth(data: SessionListResponse): Boolean { + val corruptProfiles = data.storage.filterValues { it == "corrupt" }.keys + _uiState.update { state -> + if (corruptProfiles.isEmpty()) { + state.copy(corruptStorageProfiles = emptySet()) + } else { + state.copy( + isLoading = false, + isLoadingMore = false, + errorMessage = null, + corruptStorageProfiles = corruptProfiles, + sessions = (state.sessions + data.sessions).distinctBy { it.id }, + hasMore = false, + ) + } + } + return corruptProfiles.isNotEmpty() + } + /** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */ fun loadSessions(forceRefresh: Boolean = false) { val requestGeneration = generation @@ -360,10 +384,7 @@ class SessionsViewModel( val localKey = "${section.name}:${section.source}:${section.excludeSources}" val inMemoryKey = requestScope?.inMemoryKey(localKey) val persistentKey = requestScope?.persistentKey(localKey) - if (forceRefresh) { - if (inMemoryKey != null) sessionsPageCache.remove(inMemoryKey) - if (persistentKey != null) SessionListCacheStore.remove(persistentKey) - } + // #1286: keep the last good cache until a healthy refresh replaces it. val cached = if (!forceRefresh && inMemoryKey != null && persistentKey != null) { sessionsPageCache.get(inMemoryKey) @@ -418,6 +439,7 @@ class SessionsViewModel( onSuccess = { data -> val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() if (requestGeneration != generation || currentScope != requestScope) return@safeLaunchLoad + if (handleStorageHealth(data)) return@safeLaunchLoad if (inMemoryKey != null) sessionsPageCache.put(inMemoryKey, data) if (persistentKey != null) SessionListCacheStore.put(persistentKey, data) rawPaginationOffset = data.nextOffset(0) @@ -495,6 +517,7 @@ class SessionsViewModel( when (result) { is NetworkResult.Success -> { val data = result.data + if (handleStorageHealth(data)) return@launch rawPaginationOffset = data.nextOffset(offset) _uiState.update { val newSessions = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6ca654c..7fcfe1eb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1785,4 +1785,5 @@ Updating %1$s requires the additional capabilities below. Nothing has been installed yet. Accept these capabilities and update? The server did not provide capability details. Accept and update + Session storage is damaged for %1$s. History may be incomplete; previously loaded sessions are kept. Restore or recover the database on the server, then refresh. diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index 43cbe479..2194e827 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -99,6 +99,72 @@ class SessionsViewModelTest { unmockkAll() } + @Test + fun `corrupt refresh retains history and recovery replaces it`() { + coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returnsMany + listOf( + Response.success(SessionListResponse(listOf(SessionInfo("cached")), total = 1)), + Response.success( + SessionListResponse(emptyList(), storage = mapOf("default" to "corrupt")), + ), + Response.success(SessionListResponse(emptyList())), + ) + val vm = createViewModel() + vm.loadSessions() + testDispatcher.scheduler.advanceUntilIdle() + vm.loadSessions(forceRefresh = true) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals( + listOf("cached"), + vm.uiState.value.sessions + .map { it.id }, + ) + assertEquals(setOf("default"), vm.uiState.value.corruptStorageProfiles) + assertFalse(vm.uiState.value.isLoading) + assertFalse(vm.uiState.value.hasMore) + vm.loadSessions(forceRefresh = true) + testDispatcher.scheduler.advanceUntilIdle() + assertTrue( + vm.uiState.value.sessions + .isEmpty(), + ) + assertTrue( + vm.uiState.value.corruptStorageProfiles + .isEmpty(), + ) + } + + @Test + fun `corrupt first page retains partial rows and flags empty history`() { + val response = + kotlinx.serialization.json.Json.decodeFromString( + """{"sessions":[],"storage":{"work":"corrupt"}}""", + ) + coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returns Response.success(response) + val vm = createViewModel() + vm.loadSessions() + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(setOf("work"), vm.uiState.value.corruptStorageProfiles) + assertFalse(vm.uiState.value.isLoading) + coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returns + Response.success(response.copy(sessions = listOf(SessionInfo("partial")))) + vm.loadSessions(forceRefresh = true) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals( + listOf("partial"), + vm.uiState.value.sessions + .map { it.id }, + ) + assertEquals(setOf("work"), vm.uiState.value.corruptStorageProfiles) + assertTrue( + kotlinx.serialization.json.Json + .decodeFromString( + """{"sessions":[]}""", + ).storage + .isEmpty(), + ) + } + @Test fun `wire paging metadata drives a second request without changing history paging`() { val first = From 5486856bd09b80bcc0c1ad8a94e01983161b1774 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:12:22 +0300 Subject: [PATCH 025/107] feat(#1285): reconcile pending/streamed turns with persisted receipts (#1299) --- .../m57/hermescontrol/ui/chat/ChatMessage.kt | 6 + .../ui/chat/ChatPersistedReceipts.kt | 30 +++ .../hermescontrol/ui/chat/ChatSendStore.kt | 2 + .../ui/chat/ChatServerMessageMapper.kt | 4 +- .../ui/chat/ChatTranscriptMerge.kt | 18 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 40 +++- .../ui/chat/ChatWsEventReducer.kt | 5 + .../ui/chat/ChatPagingMergeTest.kt | 7 +- .../ui/chat/ChatPersistedReceiptsTest.kt | 179 ++++++++++++++++++ .../ui/chat/ChatViewModelTest.kt | 106 +++++++++++ 10 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistedReceipts.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistedReceiptsTest.kt 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 4a33fb46..badd3eb2 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 @@ -107,6 +107,12 @@ data class ChatMessage( * Transient; UNKNOWN provenance and the persisted row remain unchanged. */ val isRestoredUnconfirmed: Boolean = false, + /** + * Gateway SQLite row id (#1285): from REST `id`, the `prompt.submit` `user_row_id` + * receipt, or `persisted_turn.final_assistant_row_id`. Null means unproven, never rejected. + * Scoped to the owning profile store; transient. + */ + val serverRowId: Long? = null, ) /** 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/ChatPersistedReceipts.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistedReceipts.kt new file mode 100644 index 00000000..08d030fc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistedReceipts.kt @@ -0,0 +1,30 @@ +package com.m57.hermescontrol.ui.chat + +/** + * `message.complete` `persisted_turn` (hermes-agent `PersistedTurn`, #1285). Only the anchors Mobile + * can bind without inference are kept; `row_ids` for intermediate rows would need positional + * guessing. A missing id is unproven, never a rejection, and only [complete] permits retiring the + * whole local turn. Row ids are scoped to the owning profile store. + */ +internal data class PersistedTurn( + val complete: Boolean, + val userRowId: Long? = null, + val finalAssistantRowId: Long? = null, +) + +/** Decoded JSON numbers may arrive as Int, Long, or Double; booleans and non-positive ids are not ids. */ +internal fun positiveRowId(value: Any?): Long? = + when (value) { + is Int, is Long -> (value as Number).toLong() + is Double -> value.takeIf { it % 1.0 == 0.0 }?.toLong() + else -> null + }?.takeIf { it > 0L } + +internal fun parsePersistedTurn(payload: Map<*, *>?): PersistedTurn? { + val raw = payload?.get("persisted_turn") as? Map<*, *> ?: return null + return PersistedTurn( + complete = raw["complete"] == true, + userRowId = positiveRowId(raw["user_row_id"]), + finalAssistantRowId = positiveRowId(raw["final_assistant_row_id"]), + ) +} 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 index a4a9263e..4b659f0e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt @@ -21,6 +21,8 @@ data class PendingSend( val attempts: Int = 0, /** Legacy source URIs are retained for an explicit user retry, never auto-dispatched. */ val requiresAttachmentRecovery: Boolean = false, + /** `prompt.submit` `user_row_id` receipt (#1285); null means unproven, not rejected. */ + val userRowId: Long? = null, ) @Serializable 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 9bc3a25b..a451dcc0 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 @@ -259,6 +259,7 @@ internal fun mapServerMessages( displayKind = msg.display_kind, tokenCount = tokenCount, completionId = completionId, + serverRowId = msg.id?.toLong()?.takeIf { it > 0L }, ), ) } @@ -274,12 +275,13 @@ internal fun mapServerMessages( // A canonical tool-result row settles a cached tool.start, not the reverse. local.copy( restId = message.canonicalRestId, + serverRowId = message.serverRowId, content = message.content, toolStatus = ToolStatus.COMPLETED, isHistoricalCache = false, ) } else { - local?.copy(restId = message.canonicalRestId) ?: message + local?.copy(restId = message.canonicalRestId, serverRowId = message.serverRowId) ?: 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 474b164b..4d0214f1 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 @@ -151,6 +151,8 @@ internal class TranscriptComparison( b: ChatMessage, ): Boolean { if (a.role != b.role) return false + // #1285: two proven row ids decide identity; a missing one is unproven, so fall through. + if (a.serverRowId != null && b.serverRowId != null) return a.serverRowId == b.serverRowId val aRestId = a.canonicalRestId val bRestId = b.canonicalRestId if (aRestId != null && bRestId != null) return aRestId == bRestId @@ -213,10 +215,20 @@ internal fun matchTranscriptMessages( val byId = existing.withIndex().associate { it.value.id to it.index } val byRestId = existing.withIndex().mapNotNull { (index, message) -> message.canonicalRestId?.let { it to index } }.toMap() + val byRowId = + existing + .withIndex() + .mapNotNull { (index, message) -> + message.serverRowId?.let { (message.role to it) to index } + }.toMap() val used = BooleanArray(existing.size) val matches = arrayOfNulls(incoming.size) incoming.forEachIndexed { index, message -> - (byId[message.id] ?: message.canonicalRestId?.let { byRestId[it] })?.let { match -> + ( + byId[message.id] + ?: message.canonicalRestId?.let { byRestId[it] } + ?: message.serverRowId?.let { byRowId[message.role to it] } + )?.let { match -> if (!used[match]) { used[match] = true matches[index] = existing[match] @@ -333,6 +345,7 @@ internal fun dedupeCachedMessages( aliases[message.id]?.let { message.copy( restId = it.canonicalRestId, + serverRowId = message.serverRowId ?: it.serverRowId, completionId = message.completionId ?: it.completionId, displayKind = message.displayKind ?: it.displayKind, isRestoredUnconfirmed = false, @@ -402,6 +415,7 @@ internal fun mergeCachedTranscriptPage( id = match.id, content = preservedContent ?: rich.content, restId = match.canonicalRestId ?: message.canonicalRestId, + serverRowId = match.serverRowId ?: message.serverRowId, completionId = match.completionId ?: message.completionId, displayKind = match.displayKind ?: message.displayKind, isRestoredUnconfirmed = @@ -439,6 +453,7 @@ internal fun mergeTranscriptWithLive( match?.role == MessageRole.USER -> { match.copy( restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id }, + serverRowId = message.serverRowId ?: match.serverRowId, displayKind = message.displayKind ?: match.displayKind, isHistoricalCache = false, isRestoredUnconfirmed = false, @@ -457,6 +472,7 @@ internal fun mergeTranscriptWithLive( } match.copy( restId = message.canonicalRestId ?: match.canonicalRestId, + serverRowId = message.serverRowId ?: match.serverRowId, content = mergedContent, timestamp = message.timestamp, isStreaming = message.isStreaming, 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 a46daaab..b4d9ba7e 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 @@ -1063,6 +1063,7 @@ class ChatViewModel( if (isCurrentSession(event.sessionId)) { mainTurnBusy = false lastMainCompletionAt = System.currentTimeMillis() + retireReceiptForPersistedTurn(parsePersistedTurn(event.rawPayload)) refreshSendReceipts() } // Buffers cleared before reduce; ViewModel resets them after @@ -1602,7 +1603,8 @@ class ChatViewModel( result: Any?, ) { val messageId = outgoingRequestById.remove(requestId) ?: return - val status = rpcResultMap(result)?.get("status") as? String + val resultMap = rpcResultMap(result) + val status = resultMap?.get("status") as? String val accepted = when (method) { WsMethods.SESSION_REDIRECT -> status == "redirected" || status == "queued" @@ -1611,6 +1613,24 @@ class ChatViewModel( } when { accepted -> { + // #1285: the submit ack names the row written for THIS input. Absent = unproven. + positiveRowId(resultMap?.get("user_row_id"))?.let { rowId -> + sendStore.update(messageId) { it.copy(userRowId = rowId) } + _uiState.update { state -> + state.copy( + messages = + state.messages.map { + if (it.id == messageId && + it.serverRowId == null + ) { + it.copy(serverRowId = rowId) + } else { + it + } + }, + ) + } + } markPendingSend(messageId, PendingSendState.ACCEPTED) if (sendStore.all().any { it.id == messageId && lastMainCompletionAt >= it.createdAt }) { refreshSendReceipts() @@ -2489,6 +2509,23 @@ class ChatViewModel( publishPendingSends() } + /** + * #1285: only a complete receipt proves the whole turn is durable, and only the receipt + * already bound to that exact `user_row_id` is retired. Partial or id-less receipts leave + * every local row to REST reconciliation; no row is ever inferred from text or position. + */ + private fun retireReceiptForPersistedTurn(turn: PersistedTurn?) { + val userRowId = turn?.userRowId?.takeIf { turn.complete } ?: return + val sessionId = _uiState.value.currentSessionId ?: return + val scope = sendScope() + sendStore + .all() + .filter { + it.scope == scope && it.sessionId == sessionId && it.userRowId == userRowId && + it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + }.forEach { removePendingSend(it.id) } + } + 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 }) } @@ -4648,6 +4685,7 @@ class ChatViewModel( content = it.text, attachments = it.attachments, timestamp = it.createdAt, + serverRowId = it.userRowId, ) } } 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 28d248be..f5dad2b2 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 @@ -515,6 +515,9 @@ object ChatWsEventReducer { effects = effects, ) } + // #1285: bind the committed final row only when this bubble carries the whole reply body; + // a stripped commentary prefix means the local bubble is not that row's exact content. + val finalRowId = parsePersistedTurn(event.rawPayload)?.finalAssistantRowId?.takeIf { text == event.text } val tps = turnUsage?.avgTps.validTpsOrNull() ?: usageState.latestTps.validTpsOrNull() val tokenCount = if (turnUsage?.outputTokens != null) { @@ -531,6 +534,7 @@ object ChatWsEventReducer { tokenCount = tokenCount, tps = tps, completionId = event.completionId, + serverRowId = finalRowId ?: streaming.serverRowId, ) ?: ChatMessage( role = MessageRole.ASSISTANT, content = text, @@ -539,6 +543,7 @@ object ChatWsEventReducer { tokenCount = tokenCount, tps = tps, completionId = event.completionId, + serverRowId = finalRowId, ) val effects = mutableListOf() val sid = state.currentSessionId 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 815a315f..69b18af1 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 @@ -316,7 +316,7 @@ class ChatPagingMergeTest { val mapped = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(live, echo)) - assertEquals(listOf(live.copy(restId = "rest-s-42")), mapped) + assertEquals(listOf(live.copy(restId = "rest-s-42", serverRowId = 42L)), mapped) } @Test @@ -328,7 +328,10 @@ class ChatPagingMergeTest { val mapped = mapServerMessages("s", listOf(serverTool(42), serverTool(43)), 0, true, cache) assertEquals( - listOf(first.copy(restId = "rest-s-42"), second.copy(restId = "rest-s-43")), + listOf( + first.copy(restId = "rest-s-42", serverRowId = 42L), + second.copy(restId = "rest-s-43", serverRowId = 43L), + ), mapped, ) assertEquals(2, mapped.map { it.id }.toSet().size) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistedReceiptsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistedReceiptsTest.kt new file mode 100644 index 00000000..969222c0 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistedReceiptsTest.kt @@ -0,0 +1,179 @@ +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.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** #1285: persisted-turn receipts are addresses, never negative acknowledgements. */ +class ChatPersistedReceiptsTest { + @Test + fun parsesCompleteReceiptWithMixedNumberTypes() { + val turn = + parsePersistedTurn( + mapOf( + "persisted_turn" to + mapOf( + "row_ids" to listOf(41, 42L, 43.0), + "complete" to true, + "user_row_id" to 41, + "final_assistant_row_id" to 43L, + ), + ), + ) + + assertEquals(PersistedTurn(true, 41L, 43L), turn) + assertEquals( + 43L, + parsePersistedTurn(mapOf("persisted_turn" to mapOf("final_assistant_row_id" to 43.0)))?.finalAssistantRowId, + ) + } + + @Test + fun missingIdsStayUnprovenAndMalformedValuesAreIgnored() { + val turn = + parsePersistedTurn( + mapOf( + "persisted_turn" to + mapOf("complete" to "true", "user_row_id" to true, "final_assistant_row_id" to 1.5), + ), + )!! + + assertFalse(turn.complete) + assertNull(positiveRowId("8")) + assertNull(positiveRowId(0)) + assertNull(positiveRowId(-1L)) + assertNull(turn.userRowId) + assertNull(turn.finalAssistantRowId) + assertNull(parsePersistedTurn(mapOf("text" to "done"))) + assertNull(parsePersistedTurn(null)) + } + + @Test + fun rowIdMatchesUserEchoEvenWhenServerTextDiffers() { + val local = ChatMessage(id = "local", role = MessageRole.USER, content = "hi", serverRowId = 42) + val rest = + ChatMessage(id = "rest-s-42", role = MessageRole.USER, content = "hi\n\n--- rewritten", serverRowId = 42) + + val merged = mergeTranscriptWithLive(listOf(rest), listOf(local), preserveLiveIds = true) + + assertEquals(listOf("local"), merged.map { it.id }) + assertEquals("rest-s-42", merged.single().restId) + assertEquals(42L, merged.single().serverRowId) + } + + @Test + fun differentRowIdsKeepRepeatedTextAsSeparateTurns() { + val local = ChatMessage(id = "local", role = MessageRole.USER, content = "again", serverRowId = 43) + val older = ChatMessage(id = "rest-s-42", role = MessageRole.USER, content = "again", serverRowId = 42) + + val merged = mergeTranscriptWithLive(listOf(older), listOf(local), preserveLiveIds = true) + + assertEquals(setOf("local", "rest-s-42"), merged.map { it.id }.toSet()) + } + + @Test + fun missingRowIdFallsBackToContentMatching() { + val local = ChatMessage(id = "local", role = MessageRole.USER, content = "hello") + val rest = ChatMessage(id = "rest-s-9", role = MessageRole.USER, content = "hello", serverRowId = 9) + + val merged = mergeTranscriptWithLive(listOf(rest), listOf(local), preserveLiveIds = true) + + assertEquals(listOf("local"), merged.map { it.id }) + assertEquals(9L, merged.single().serverRowId) + } + + @Test + fun completeReceiptBindsFinalAssistantRow() { + val result = + complete( + "answer", + mapOf( + "row_ids" to listOf(1, 2), + "complete" to true, + "final_assistant_row_id" to 2, + ), + ) + + assertEquals( + 2L, + result.state.messages + .single() + .serverRowId, + ) + } + + @Test + fun partialReceiptStillBindsProvenFinalRowButKeepsLocalReply() { + val result = + complete("answer", mapOf("row_ids" to listOf(2), "complete" to false, "final_assistant_row_id" to 2)) + + assertEquals( + "answer", + result.state.messages + .single() + .content, + ) + assertEquals( + 2L, + result.state.messages + .single() + .serverRowId, + ) + } + + @Test + fun receiptWithoutFinalIdLeavesReplyUnbound() { + val result = complete("answer", mapOf("row_ids" to listOf(1), "complete" to false, "user_row_id" to 1)) + + assertEquals( + "answer", + result.state.messages + .single() + .content, + ) + assertNull( + result.state.messages + .single() + .serverRowId, + ) + } + + @Test + fun strippedCommentaryPrefixDoesNotClaimFinalRow() { + val sealed = ChatMessage(id = "sealed", role = MessageRole.ASSISTANT, content = "Looking. ") + val result = + ChatWsEventReducer.reduce( + state = ChatUiState(currentSessionId = "session-1", messages = listOf(sealed)), + streamingState = StreamingState(sealedOrphanIds = listOf("sealed")), + event = + WsEvent.MessageComplete( + "Looking. Done", + "session-1", + rawPayload = + mapOf( + "persisted_turn" to + mapOf("row_ids" to listOf(5), "complete" to true, "final_assistant_row_id" to 5), + ), + ), + currentSessionId = "session-1", + ) + + val reply = result.state.messages.single { it.id != "sealed" } + assertEquals("Done", reply.content) + assertNull(reply.serverRowId) + assertTrue(result.state.messages.none { it.serverRowId == 5L }) + } + + private fun complete( + text: String, + receipt: Map, + ) = ChatWsEventReducer.reduce( + state = ChatUiState(currentSessionId = "session-1"), + streamingState = StreamingState(), + event = WsEvent.MessageComplete(text, "session-1", rawPayload = mapOf("persisted_turn" to receipt)), + currentSessionId = "session-1", + ) +} 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 c2a9f6f5..4048d080 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 @@ -3516,6 +3516,112 @@ class ChatViewModelTest { assertEquals(PendingSendState.UNKNOWN, sendStore.all().single().state) } + @Test + fun completePersistedTurnRetiresOnlyTheReceiptBoundToItsUserRow() = + runTest { + val sendStore = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore) + every { HermesWsClient.sendMessage(sessionId, "Durable", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke("durable-submit") + "durable-submit" + } + viewModel.sendMessage("Durable") + advanceUntilIdle() + mockEventsFlow.emit( + WsEvent.RpcResult("durable-submit", mapOf("status" to "streaming", "user_row_id" to 77)), + ) + advanceUntilIdle() + + val receipt = sendStore.all().single() + assertEquals(77L, receipt.userRowId) + assertEquals( + 77L, + viewModel.uiState.value.messages + .single { it.id == receipt.id } + .serverRowId, + ) + + // Partial and foreign receipts never retire the local turn. + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit( + WsEvent.MessageComplete( + "Reply", + sessionId, + rawPayload = + mapOf( + "persisted_turn" to + mapOf( + "row_ids" to listOf(77), + "complete" to false, + "user_row_id" to 77, + ), + ), + ), + ) + advanceUntilIdle() + assertEquals(listOf(receipt.id), sendStore.all().map { it.id }) + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit( + WsEvent.MessageComplete( + "Reply", + sessionId, + rawPayload = + mapOf( + "persisted_turn" to mapOf("row_ids" to listOf(78), "complete" to true, "user_row_id" to 78), + ), + ), + ) + advanceUntilIdle() + assertEquals(listOf(receipt.id), sendStore.all().map { it.id }) + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit( + WsEvent.MessageComplete( + "Reply", + sessionId, + rawPayload = + mapOf( + "persisted_turn" to + mapOf( + "row_ids" to listOf(77, 79), + "complete" to true, + "user_row_id" to 77, + "final_assistant_row_id" to 79, + ), + ), + ), + ) + advanceUntilIdle() + assertTrue(sendStore.all().isEmpty()) + assertTrue( + viewModel.uiState.value.messages + .any { it.id == receipt.id }, + ) + } + + @Test + fun submitAckWithoutUserRowIdLeavesReceiptUnproven() = + runTest { + val sendStore = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore) + every { HermesWsClient.sendMessage(sessionId, "Legacy", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke("legacy-submit") + "legacy-submit" + } + viewModel.sendMessage("Legacy") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult("legacy-submit", mapOf("status" to "streaming"))) + advanceUntilIdle() + + val receipt = sendStore.all().single() + assertNull(receipt.userRowId) + assertEquals(PendingSendState.ACCEPTED, receipt.state) + assertTrue( + viewModel.uiState.value.messages + .any { it.id == receipt.id && it.serverRowId == null }, + ) + } + @Test fun busyGuideUsesSteerAndUnsupportedFallsBackToQueueWithoutInterrupt() = runTest { From 7c09810b23b1af1c8853255af8fb3245535d4c70 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:08:57 +0300 Subject: [PATCH 026/107] feat(#1287): plugin/skill install approval cards (#1301) --- .../components/ConnectionSetupSheetTest.kt | 115 +++++++++++++++++ .../data/model/ConnectionOperationModels.kt | 38 +++++- .../data/ws/ConnectionOperationParser.kt | 57 +++++++- .../components/ConnectionCatalogDetails.kt | 122 ++++++++++++++++++ .../chat/components/ConnectionSetupSheet.kt | 40 +++++- app/src/main/res/values-ar/strings.xml | 18 +++ app/src/main/res/values-ko/strings.xml | 18 +++ app/src/main/res/values-zh/strings.xml | 18 +++ app/src/main/res/values/strings.xml | 18 +++ .../data/ws/ConnectionOperationParserTest.kt | 68 ++++++++++ 10 files changed, 502 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionCatalogDetails.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt index d8f4ab7b..daee1c97 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt @@ -1,5 +1,8 @@ package com.m57.hermescontrol.ui.chat.components +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled @@ -11,6 +14,9 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.CatalogScanStatus +import com.m57.hermescontrol.data.model.ConnectionCatalogInfo +import com.m57.hermescontrol.data.model.ConnectionCatalogScan import com.m57.hermescontrol.data.model.ConnectionEnvField import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot import com.m57.hermescontrol.data.model.ConnectionOperationTarget @@ -212,6 +218,115 @@ class ConnectionSetupSheetTest { composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist() } + @Test + fun pluginTarget_showsSourcePinScanAndRequirements_andApprovesWithoutSecrets() { + var response: Triple, Boolean>? = null + composeTestRule.setContent { + ConnectionSetupContent( + state = state(pluginTarget()), + onRespond = { name, values, approved -> response = Triple(name, values, approved) }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithText("Install Linear").assertIsDisplayed() + composeTestRule + .onNodeWithTag( + "connection_catalog_source", + ).assertTextContains("NousResearch/hermes-plugins/linear", substring = true) + composeTestRule.onNodeWithTag("connection_catalog_pin").assertTextContains("0123456789ab", substring = true) + composeTestRule + .onNodeWithTag("connection_catalog_scan") + .assertTextContains("Security scan found warnings · 1 advisory") + composeTestRule + .onNodeWithTag( + "connection_catalog_requirements", + ).assertTextContains("Hermes >=0.21", substring = true) + composeTestRule.onNodeWithTag("connection_catalog_profile").assertTextContains("default", substring = true) + composeTestRule.onNodeWithText("This setup step is not supported by this app version.").assertDoesNotExist() + + composeTestRule.onNodeWithTag("connection_setup_connect").assertTextContains("Install").performClick() + + assertEquals("linear", response?.first) + assertEquals(mapOf("LINEAR_API_KEY" to ""), response?.second) + assertEquals(true, response?.third) + } + + @Test + fun pluginTarget_installingAndFailedStates() { + var target = pluginTarget(state = ConnectionTargetState.INITIATED) + var current by mutableStateOf(state(target)) + composeTestRule.setContent { + ConnectionSetupContent( + state = current, + onRespond = { _, _, _ -> }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithTag("connection_catalog_installing").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_connect").assertDoesNotExist() + + target = pluginTarget(state = ConnectionTargetState.FAILED, detail = "blocked by the kill list") + current = state(target) + composeTestRule.onNodeWithText("blocked by the kill list").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_connect").assertTextContains("Try again") + } + + @Test + fun installedSkill_reportsTheLoadableSkill() { + composeTestRule.setContent { + ConnectionSetupContent( + state = + state( + pluginTarget(state = ConnectionTargetState.CONNECTED).copy( + kind = ConnectionTargetKind.SKILL, + catalog = pluginCatalog().copy(display = "Review", skill = "review", scan = null), + ), + ), + onRespond = { _, _, _ -> }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithText("Skill available: review").assertIsDisplayed() + composeTestRule.onNodeWithText("No security scan reported").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist() + } + + private fun pluginCatalog(): ConnectionCatalogInfo = + ConnectionCatalogInfo( + display = "Linear", + description = "Linear issues.", + tier = "official", + platforms = emptyList(), + repo = "NousResearch/hermes-plugins", + sha = "0123456789abcdef0123456789abcdef01234567", + subdir = "linear", + scan = ConnectionCatalogScan(CatalogScanStatus.WARNINGS, "1 advisory"), + requirements = listOf("Hermes >=0.21"), + targetProfile = "default", + skill = null, + ) + + private fun pluginTarget( + state: ConnectionTargetState = ConnectionTargetState.PENDING, + detail: String? = null, + ): ConnectionOperationTarget = + target( + name = "linear", + state = state, + requiredEnv = listOf(env("LINEAR_API_KEY", required = false, secret = true)), + ).copy( + kind = ConnectionTargetKind.PLUGIN, + action = ConnectionTargetAction.INSTALL, + detail = detail, + catalog = pluginCatalog(), + ) + private fun state(target: ConnectionOperationTarget): ConnectionOperationUiState = ConnectionOperationUiState(operation = operation(target)) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt index 064ca0b9..31a9038b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt @@ -29,6 +29,8 @@ data class ConnectionOperationTarget( val requiredEnv: List, val tools: List, val hint: String?, + /** Catalog facts for [ConnectionTargetKind.PLUGIN] / [ConnectionTargetKind.SKILL] rows only. */ + val catalog: ConnectionCatalogInfo? = null, ) { /** Safe validated browser URL. OAuth query parameters stay redacted from [toString]. */ val safeConnectUrl: String? @@ -40,9 +42,32 @@ data class ConnectionOperationTarget( "instructions=$instructions, discoveryError=$discoveryError, " + "connectUrl=${if (connectUrl == null) "null" else "[REDACTED]"}, " + "connectionId=$connectionId, attempt=$attempt, requiredEnv=$requiredEnv, " + - "tools=$tools, hint=$hint)" + "tools=$tools, hint=$hint, catalog=$catalog)" } +/** Host-resolved catalog row facts (hermes-agent `manage_catalog`); the model supplies only the id. */ +data class ConnectionCatalogInfo( + val display: String, + val description: String?, + val tier: String?, + val platforms: List, + val repo: String?, + val sha: String?, + val subdir: String?, + val scan: ConnectionCatalogScan?, + val requirements: List, + val targetProfile: String, + /** On an installed row: the skill the model can now load. */ + val skill: String?, +) + +data class ConnectionCatalogScan( + val status: CatalogScanStatus, + val summary: String?, +) + +enum class CatalogScanStatus { PASSED, WARNINGS, FAILED, UNKNOWN } + data class ConnectionEnvField( val name: String, val required: Boolean, @@ -51,7 +76,16 @@ data class ConnectionEnvField( val prompt: String?, ) -enum class ConnectionTargetKind { CONNECTOR, MCP, UNKNOWN } +enum class ConnectionTargetKind { + CONNECTOR, + MCP, + PLUGIN, + SKILL, + UNKNOWN, + ; + + val isCatalog: Boolean get() = this == PLUGIN || this == SKILL +} enum class ConnectionTargetAction { AUTHORIZE, diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt index c7c83123..088c1e4e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt @@ -1,5 +1,8 @@ package com.m57.hermescontrol.data.ws +import com.m57.hermescontrol.data.model.CatalogScanStatus +import com.m57.hermescontrol.data.model.ConnectionCatalogInfo +import com.m57.hermescontrol.data.model.ConnectionCatalogScan import com.m57.hermescontrol.data.model.ConnectionEnvField import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot import com.m57.hermescontrol.data.model.ConnectionOperationTarget @@ -58,14 +61,15 @@ object ConnectionOperationParser { val map = raw ?: return null val name = (map["name"] as? String)?.trim() ?: return null if (name.isBlank()) return null + val kind = + enumValue( + map["kind"] as? String, + ConnectionTargetKind.values(), + ConnectionTargetKind.UNKNOWN, + ) return ConnectionOperationTarget( name = name, - kind = - enumValue( - map["kind"] as? String, - ConnectionTargetKind.values(), - ConnectionTargetKind.UNKNOWN, - ), + kind = kind, action = enumValue( map["action"] as? String, @@ -90,9 +94,50 @@ object ConnectionOperationParser { ?: emptyList(), tools = (map["tools"] as? List<*>)?.filterIsInstance() ?: emptyList(), hint = map["hint"] as? String, + catalog = if (kind.isCatalog) parseCatalog(map, name) else null, ) } + private fun parseCatalog( + map: Map<*, *>, + name: String, + ): ConnectionCatalogInfo = + ConnectionCatalogInfo( + display = map.text("display") ?: name, + description = map.text("description"), + tier = map.text("tier"), + platforms = map.strings("platforms"), + repo = map.text("repo"), + sha = map.text("sha"), + subdir = map.text("subdir"), + scan = + (map["scan"] as? Map<*, *>)?.let { scan -> + ConnectionCatalogScan( + status = + enumValue( + scan["status"] as? String, + CatalogScanStatus.values(), + CatalogScanStatus.UNKNOWN, + ), + summary = scan.text("summary"), + ) + }, + requirements = map.strings("requirements"), + targetProfile = map.text("target_profile") ?: DEFAULT_PROFILE, + skill = map.text("skill"), + ) + + private fun Map<*, *>.text(key: String): String? = (this[key] as? String)?.trim()?.takeIf { it.isNotEmpty() } + + private fun Map<*, *>.strings(key: String): List = + (this[key] as? List<*>) + ?.filterIsInstance() + ?.map(String::trim) + ?.filter(String::isNotEmpty) + ?: emptyList() + + private const val DEFAULT_PROFILE = "default" + private fun parseEnv(raw: Map<*, *>?): ConnectionEnvField? { val map = raw ?: return null val name = (map["name"] as? String)?.trim() ?: return null diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionCatalogDetails.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionCatalogDetails.kt new file mode 100644 index 00000000..64a7a92c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionCatalogDetails.kt @@ -0,0 +1,122 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 com.m57.hermescontrol.data.model.CatalogScanStatus +import com.m57.hermescontrol.data.model.ConnectionCatalogInfo +import com.m57.hermescontrol.data.model.ConnectionTargetKind +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +private const val SHORT_SHA_LENGTH = 12 + +/** What a plugin/skill install row would bring in: source, pin, scan and requirements (#1287). */ +@Composable +internal fun ConnectionCatalogDetails( + kind: ConnectionTargetKind, + catalog: ConnectionCatalogInfo, + modifier: Modifier = Modifier, +) { + val muted = MaterialTheme.colorScheme.onSurfaceVariant + Column( + modifier = modifier.testTag("connection_catalog_details"), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val kindLabel = + stringResource( + if (kind == ConnectionTargetKind.SKILL) { + R.string.connection_catalog_kind_skill + } else { + R.string.connection_catalog_kind_plugin + }, + ) + val tierLabel = + when (catalog.tier) { + null -> null + "official" -> stringResource(R.string.connection_catalog_tier_official) + "community" -> stringResource(R.string.connection_catalog_tier_community) + else -> catalog.tier + } + Text( + text = listOfNotNull(kindLabel, tierLabel).joinToString(" · "), + style = MaterialTheme.typography.labelLarge, + color = muted, + ) + catalog.description?.let { Text(it) } + catalog.repo?.let { repo -> + val source = catalog.subdir?.let { "$repo/$it" } ?: repo + Text( + text = stringResource(R.string.connection_catalog_source, source), + color = muted, + modifier = Modifier.testTag("connection_catalog_source"), + ) + } + catalog.sha?.let { sha -> + Text( + text = stringResource(R.string.connection_catalog_pin, sha.take(SHORT_SHA_LENGTH)), + color = muted, + modifier = Modifier.testTag("connection_catalog_pin"), + ) + } + if (catalog.platforms.isNotEmpty()) { + Text( + text = stringResource(R.string.connection_catalog_platforms, catalog.platforms.joinToString(", ")), + color = muted, + ) + } + CatalogScanLine(catalog) + if (catalog.requirements.isNotEmpty()) { + Text( + text = + stringResource( + R.string.connection_catalog_requirements, + catalog.requirements.joinToString(", "), + ), + color = muted, + modifier = Modifier.testTag("connection_catalog_requirements"), + ) + } + Text( + text = stringResource(R.string.connection_catalog_target_profile, catalog.targetProfile), + color = muted, + modifier = Modifier.testTag("connection_catalog_profile"), + ) + } +} + +@Composable +private fun CatalogScanLine(catalog: ConnectionCatalogInfo) { + val status = LocalHermesStatusColors.current + val scan = catalog.scan + val (label, color) = + when (scan?.status) { + CatalogScanStatus.PASSED -> { + R.string.connection_catalog_scan_passed to status.success + } + + CatalogScanStatus.WARNINGS -> { + R.string.connection_catalog_scan_warnings to status.warning + } + + CatalogScanStatus.FAILED -> { + R.string.connection_catalog_scan_failed to status.error + } + + CatalogScanStatus.UNKNOWN, null -> { + R.string.connection_catalog_scan_unreported to MaterialTheme.colorScheme.onSurfaceVariant + } + } + val summary = scan?.summary + Text( + text = stringResource(label) + if (summary != null) " · $summary" else "", + color = color, + modifier = Modifier.testTag("connection_catalog_scan"), + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt index 19c45c50..31ed7619 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt @@ -102,8 +102,14 @@ internal fun ConnectionSetupContent( .padding(horizontal = 20.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { + val catalog = target.catalog Text( - text = stringResource(R.string.connection_setup_target_title, target.name), + text = + if (catalog != null) { + stringResource(R.string.connection_catalog_title, catalog.display) + } else { + stringResource(R.string.connection_setup_target_title, target.name) + }, style = MaterialTheme.typography.titleLarge, modifier = Modifier.testTag("connection_setup_title"), ) @@ -120,6 +126,7 @@ internal fun ConnectionSetupContent( target.instructions?.takeIf { it.isNotBlank() }?.let { instructions -> Text(instructions, color = MaterialTheme.colorScheme.onSurfaceVariant) } + catalog?.let { ConnectionCatalogDetails(kind = target.kind, catalog = it) } when { target.state == ConnectionTargetState.CONNECTED && !target.discoveryError.isNullOrBlank() -> { @@ -141,6 +148,12 @@ internal fun ConnectionSetupContent( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + catalog?.skill?.let { skill -> + Text( + text = stringResource(R.string.connection_catalog_skill_ready, skill), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } target.detail?.takeIf { it.isNotBlank() }?.let { detail -> Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -167,6 +180,21 @@ internal fun ConnectionSetupContent( } } + catalog != null && target.state == ConnectionTargetState.INITIATED -> { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.testTag("connection_catalog_installing"), + ) { + CircularProgressIndicator() + Text( + text = + target.detail?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.connection_catalog_installing), + ) + } + } + target.state == ConnectionTargetState.UNKNOWN || (target.state == ConnectionTargetState.INITIATED && target.connectUrl != null) -> { Text( @@ -220,7 +248,15 @@ internal fun ConnectionSetupContent( .fillMaxWidth() .testTag("connection_setup_connect"), ) { - Text(stringResource(R.string.connection_setup_connect)) + Text( + stringResource( + when { + catalog == null -> R.string.connection_setup_connect + target.state == ConnectionTargetState.FAILED -> R.string.connection_catalog_retry + else -> R.string.connection_catalog_install + }, + ), + ) } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 858a5b53..15e5b90e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1557,6 +1557,24 @@ %1$s مطلوب تعذر إرسال رد الإعداد. حاول مرة أخرى. بانتظار Hermes… + تثبيت %1$s + إضافة + مهارة + رسمي + مجتمعي + المصدر: %1$s + الإيداع المثبّت: %1$s + المنصات: %1$s + المتطلبات: %1$s + يُثبَّت في الملف الشخصي: %1$s + اجتاز الفحص الأمني + وجد الفحص الأمني تحذيرات + فشل الفحص الأمني + لم يُبلَّغ عن فحص أمني + تثبيت + حاول مجددًا + جارٍ التثبيت… + المهارة متاحة: %1$s أثناء عمل الوكيل اختر كيفية وصول الرسالة الجديدة إلى المحادثة الحالية. تُرسل الرسائل بشكل عادي عند الخمول. diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d85ee3c7..c659080f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1213,6 +1213,24 @@ %1$s은(는) 필수입니다 설정 응답을 보낼 수 없습니다. 다시 시도하세요. Hermes를 기다리는 중… + %1$s 설치 + 플러그인 + 스킬 + 공식 + 커뮤니티 + 소스: %1$s + 고정 커밋: %1$s + 플랫폼: %1$s + 요구 사항: %1$s + 설치 대상 프로필: %1$s + 보안 검사 통과 + 보안 검사에서 경고 발견 + 보안 검사 실패 + 보고된 보안 검사 없음 + 설치 + 다시 시도 + 설치 중… + 사용 가능한 스킬: %1$s 작업 지정 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 43541c9f..eac2130b 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1509,6 +1509,24 @@ %1$s为必填项 无法发送设置响应,请重试。 正在等待 Hermes… + 安装 %1$s + 插件 + 技能 + 官方 + 社区 + 来源:%1$s + 固定提交:%1$s + 平台:%1$s + 要求:%1$s + 安装到配置文件:%1$s + 安全扫描已通过 + 安全扫描发现警告 + 安全扫描失败 + 未报告安全扫描 + 安装 + 重试 + 正在安装… + 可用技能:%1$s 智能体工作时 选择新消息如何送达当前聊天。空闲时正常发送。 修正当前轮次 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7fcfe1eb..52a2de07 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1742,6 +1742,24 @@ %1$s is required Couldn’t send the setup response. Try again. Waiting for Hermes… + Install %1$s + Plugin + Skill + Official + Community + Source: %1$s + Pinned commit: %1$s + Platforms: %1$s + Requires: %1$s + Installs into profile: %1$s + Security scan passed + Security scan found warnings + Security scan failed + No security scan reported + Install + Try again + Installing… + Skill available: %1$s Specify task diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt index 132ac68a..f5f4172f 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.ws +import com.m57.hermescontrol.data.model.CatalogScanStatus import com.m57.hermescontrol.data.model.ConnectionTargetAction import com.m57.hermescontrol.data.model.ConnectionTargetKind import com.m57.hermescontrol.data.model.ConnectionTargetState @@ -60,6 +61,73 @@ class ConnectionOperationParserTest { assertEquals(ConnectionTargetState.UNKNOWN, target.state) } + @Test + fun pluginAndSkillTargets_parseKindAndCatalogFacts() { + val payload = + completePayload() + + ( + "targets" to + listOf( + mapOf( + "name" to "linear", + "kind" to "plugin", + "action" to "install", + "state" to "pending", + "display" to " Linear ", + "description" to "Linear issues.", + "tier" to "official", + "repo" to "NousResearch/hermes-plugins", + "sha" to "0123456789abcdef0123456789abcdef01234567", + "subdir" to "linear", + "scan" to mapOf("status" to "warnings", "summary" to "1 advisory"), + "requirements" to listOf("Hermes >=0.21", "LINEAR_API_KEY environment variable"), + "platforms" to listOf("linux", ""), + "target_profile" to "work", + "required_env" to + listOf(mapOf("name" to "LINEAR_API_KEY", "required" to false, "secret" to true)), + ), + mapOf( + "name" to "owner/skills/review", + "kind" to "skill", + "action" to "install", + "state" to "connected", + "skill" to "review", + "scan" to mapOf("status" to "future-status"), + ), + ) + ) + + val (plugin, skill) = ConnectionOperationParser.parse(payload, "runtime-session")!!.targets + + assertEquals(ConnectionTargetKind.PLUGIN, plugin.kind) + val catalog = plugin.catalog!! + assertEquals("Linear", catalog.display) + assertEquals("official", catalog.tier) + assertEquals("NousResearch/hermes-plugins", catalog.repo) + assertEquals("0123456789abcdef0123456789abcdef01234567", catalog.sha) + assertEquals("linear", catalog.subdir) + assertEquals(CatalogScanStatus.WARNINGS, catalog.scan?.status) + assertEquals("1 advisory", catalog.scan?.summary) + assertEquals(2, catalog.requirements.size) + assertEquals(listOf("linux"), catalog.platforms) + assertEquals("work", catalog.targetProfile) + assertEquals(listOf("LINEAR_API_KEY"), plugin.requiredEnv.map { it.name }) + + assertEquals(ConnectionTargetKind.SKILL, skill.kind) + assertEquals("owner/skills/review", skill.catalog?.display) + assertEquals("default", skill.catalog?.targetProfile) + assertEquals("review", skill.catalog?.skill) + assertEquals(CatalogScanStatus.UNKNOWN, skill.catalog?.scan?.status) + assertNull(skill.catalog?.repo) + } + + @Test + fun nonCatalogTargets_carryNoCatalogFacts() { + val targets = ConnectionOperationParser.parse(completePayload(), "runtime-session")!!.targets + + assertTrue(targets.all { it.catalog == null }) + } + @Test fun malformedIdentityOrDeadline_isRejected() { assertNull(ConnectionOperationParser.parse(completePayload() - "op_id", "runtime-session")) From 3aedb44bedf1fd469787f1cf7cafc8fbba55e798 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:22:49 +0300 Subject: [PATCH 027/107] feat(#1288): account-owned connectors management and tool policy (#1302) --- .../com/m57/hermescontrol/NavigationKeys.kt | 2 + .../com/m57/hermescontrol/ScreenRegistry.kt | 7 + .../data/model/ConnectionOperationModels.kt | 1 + .../data/model/ConnectorModels.kt | 67 ++++ .../data/ws/AccountConnectorRepository.kt | 341 ++++++++++++++++++ .../data/ws/ConnectionOperationParser.kt | 1 + .../m57/hermescontrol/data/ws/WsMethods.kt | 13 + .../chat/ChatConnectionOperationDelegate.kt | 21 +- .../m57/hermescontrol/ui/chat/ToolBubble.kt | 5 +- .../hermescontrol/ui/chat/ToolResultParser.kt | 11 +- .../hermescontrol/ui/chat/tool/ToolView.kt | 1 + .../ui/chat/tool/ToolViewBuilder.kt | 30 +- .../ui/connectors/AccountConnectorsScreen.kt | 278 ++++++++++++++ .../connectors/AccountConnectorsViewModel.kt | 268 ++++++++++++++ .../ui/settings/SettingsScreen.kt | 8 + app/src/main/res/values/strings.xml | 15 + .../data/ws/AccountConnectorRepositoryTest.kt | 255 +++++++++++++ .../ui/chat/tool/ConnectorToolLabelsTest.kt | 40 ++ .../AccountConnectorsViewModelTest.kt | 207 +++++++++++ 19 files changed, 1557 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsScreen.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ConnectorToolLabelsTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 7f863c92..42ec81c3 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -80,6 +80,8 @@ import kotlinx.serialization.Serializable @Serializable data object FilesScreen : NavKey +@Serializable data object AccountConnectorsScreen : NavKey + // ── Settings drill-down sub-pages ────────────────────────────────────── @Serializable data object SettingsConnection : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt index f812b30a..eadc0974 100644 --- a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt @@ -39,6 +39,7 @@ import com.m57.hermescontrol.ui.bots.BotsScreen as BotsScreenContent import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent +import com.m57.hermescontrol.ui.connectors.AccountConnectorsScreen as AccountConnectorsScreenContent import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent import com.m57.hermescontrol.ui.files.FilesScreen as FilesScreenContent import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent @@ -236,6 +237,12 @@ object ScreenRegistry { Icons.Filled.EmojiEvents, DrawerSection.INSPECT, ) { sessionId, openDrawer -> AchievementsScreenContent(onOpenDrawer = openDrawer) }, + ScreenDefinition( + AccountConnectorsScreen, + R.string.screen_account_connectors, + Icons.Filled.Extension, + DrawerSection.CONFIGURE, + ) { _, openDrawer -> AccountConnectorsScreenContent(onOpenDrawer = openDrawer) }, ScreenDefinition( SettingsScreen, R.string.screen_settings, diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt index 31a9038b..e5a9167c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt @@ -13,6 +13,7 @@ data class ConnectionOperationSnapshot( val settled: Boolean, val settledBy: String?, val targets: List, + val accountOwned: Boolean = false, ) data class ConnectionOperationTarget( diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt index 13d16db0..092af4aa 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt @@ -231,3 +231,70 @@ sealed interface ConnectorConnectResult { fun errorOrNull(): ConnectorError? = (this as? Error)?.error } + +/** Account-level connector records exposed outside the chat session lifecycle. */ +data class ConnectorAccount( + val connectionId: String, + val connector: String, + val status: String, + val statusReason: String?, + val label: String, + val alias: String?, + val active: Boolean, + val createdAt: String, + val updatedAt: String, +) + +data class ConnectorCatalogEntry( + val slug: String, + val name: String, + val description: String, + val category: String, +) + +data class ConnectorTool( + val slug: String, + val name: String, + val description: String, + val facet: String, + val deprecated: Boolean, + val hints: List = emptyList(), +) + +data class ConnectorPolicy( + val revision: String, + val mode: String, + val connectors: List, + val disabledConnectors: List, + val tools: Map>, + val enabledTags: List = emptyList(), + val disabledTags: List = emptyList(), + val member: ConnectorPolicy? = null, + val inherited: List = emptyList(), +) { + fun toolEnabled( + slug: String, + tool: ConnectorTool, + ): Boolean = + connectorEnabled(slug) && tool.slug !in tools[slug].orEmpty() && + disabledTags.none { it in tool.hints } && (enabledTags.isEmpty() || enabledTags.any { it in tool.hints }) + + fun connectorEnabled(slug: String): Boolean = + when (mode) { + "unrestricted" -> true + "deny-all" -> false + "allow" -> slug in connectors + "deny" -> slug !in disabledConnectors + else -> false + } +} + +sealed interface AccountConnectorResult { + data class Success( + val value: T, + ) : AccountConnectorResult + + data class Failure( + val error: ConnectorError, + ) : AccountConnectorResult +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt new file mode 100644 index 00000000..9a5fee1d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt @@ -0,0 +1,341 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.AccountConnectorResult +import com.m57.hermescontrol.data.model.ConnectorAccount +import com.m57.hermescontrol.data.model.ConnectorCatalogEntry +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorPolicy +import com.m57.hermescontrol.data.model.ConnectorTool +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +interface AccountConnectorRepository { + suspend fun catalog(): AccountConnectorResult> + + suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult + + suspend fun connect( + slugs: List, + reconnect: Boolean = false, + ): AccountConnectorResult + + suspend fun accounts(): AccountConnectorResult> + + suspend fun remove(connectionId: String): AccountConnectorResult + + suspend fun tools( + slug: String, + refresh: Boolean = false, + ): AccountConnectorResult> + + suspend fun policy(): AccountConnectorResult + + suspend fun setConnectorEnabled( + slug: String, + enabled: Boolean, + expectedRevision: String, + ): AccountConnectorResult + + suspend fun setDisabledTools( + slug: String, + disabled: List, + expectedRevision: String, + ): AccountConnectorResult + + suspend fun operationRequest( + method: String, + params: Map, + ): Any? + + suspend fun operationStatus( + opId: String, + ): AccountConnectorResult + + companion object : AccountConnectorRepository by HermesAccountConnectorRepository() +} + +class HermesAccountConnectorRepository( + private val rpc: suspend (String, Map) -> Any? = { method, params -> + val deferred = HermesWsClient.request(method, params) + try { + deferred.await() + } catch ( + e: CancellationException, + ) { + deferred.cancel(e) + throw e + } finally { + if (!deferred.isCompleted) deferred.cancel() + } + }, +) : AccountConnectorRepository { + private suspend fun request( + method: String, + params: Map = emptyMap(), + ) = rpc(method, params) + + private suspend fun guarded(block: suspend () -> T): AccountConnectorResult = + try { + AccountConnectorResult.Success(block()) + } catch (e: CancellationException) { + throw e + } catch (e: HermesWsClient.HermesRpcException) { + AccountConnectorResult.Failure(ConnectorParser.mapRpcError(e.code, e.message, e.data)) + } catch (_: IllegalArgumentException) { + AccountConnectorResult.Failure(ConnectorError.InvalidResponse()) + } catch (_: IllegalStateException) { + AccountConnectorResult.Failure(ConnectorError.InvalidResponse()) + } catch ( + _: Exception, + ) { + AccountConnectorResult.Failure(ConnectorError.NetworkError("Failed to communicate with gateway.")) + } + + private fun accountParams(params: Map = emptyMap()): Map = + buildMap { + putAll(params) + AuthManager.activeProfileId.value?.let { put("profile", it) } + } + + private fun obj(value: Any?): JsonObject = ConnectorParser.toJsonElement(value).jsonObject + + private fun JsonObject.str(k: String) = this[k]?.jsonPrimitive?.contentOrNull.orEmpty() + + private fun JsonObject.bool(k: String) = this[k]?.jsonPrimitive?.contentOrNull == "true" + + private fun JsonObject.array(k: String): JsonArray = this[k]?.jsonArray ?: JsonArray(emptyList()) + + private fun JsonObject.stringList(k: String) = array(k).mapNotNull { it.jsonPrimitive.contentOrNull } + + override suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult = + try { + ConnectorParser.parseListResult( + rpc( + WsMethods.CONNECTORS_LIST, + accountParams( + mapOf("owner" to mapOf("type" to "account")), + ), + ), + ) + } catch ( + e: CancellationException, + ) { + throw e + } catch ( + e: HermesWsClient.HermesRpcException, + ) { + com.m57.hermescontrol.data.model.ConnectorListResult.Error( + ConnectorParser.mapRpcError(e.code, e.message, e.data), + ) + } catch ( + _: Exception, + ) { + com.m57.hermescontrol.data.model.ConnectorListResult + .Error(ConnectorError.NetworkError()) + } + + override suspend fun connect( + slugs: List, + reconnect: Boolean, + ) = guarded { + require(slugs.isNotEmpty() && slugs.all(ConnectorRepository::isValidSlug)) + parseOperation( + request( + WsMethods.CONNECTORS_CONNECT, + accountParams( + mapOf( + "owner" to mapOf("type" to "account"), + "connectors" to slugs, + "reconnect" to reconnect, + ), + ), + ), + ) + } + + override suspend fun operationRequest( + method: String, + params: Map, + ): Any? = request(method, accountParams(params + ("owner" to mapOf("type" to "account")))) + + override suspend fun operationStatus(opId: String) = + guarded { + parseOperation(operationRequest(WsMethods.CONNECTORS_OPERATION_STATUS, mapOf("op_id" to opId))) + } + + @Suppress("UNCHECKED_CAST") + private fun parseOperation(raw: Any?): com.m57.hermescontrol.data.model.ConnectionOperationSnapshot = + checkNotNull( + ConnectionOperationParser.parse( + (ConnectorParser.toJsonElement(raw).toAny() as Map) + + ("owner" to mapOf("type" to "account")), + ), + ) + + override suspend fun catalog() = + guarded { + obj(request(WsMethods.CONNECTORS_CATALOG, accountParams()))["connectors"]!!.jsonArray.map { + val row = it.jsonObject + ConnectorCatalogEntry(row.str("slug"), row.str("name"), row.str("description"), row.str("category")) + } + } + + override suspend fun accounts() = + guarded { + obj(request(WsMethods.CONNECTORS_ACCOUNTS, accountParams()))["accounts"]!!.jsonArray.map { e -> + val r = e.jsonObject + ConnectorAccount( + r.str("connection_id"), + r.str("connector"), + r.str("status"), + r["status_reason"]?.jsonPrimitive?.contentOrNull, + r.str("label"), + r["alias"]?.jsonPrimitive?.contentOrNull, + r.bool("active"), + r.str("created_at"), + r.str("updated_at"), + ) + } + } + + override suspend fun remove(connectionId: String) = + guarded { + require(connectionId.isNotBlank()) + val response = + obj( + request( + WsMethods.CONNECTORS_ACCOUNTS_REMOVE, + accountParams( + mapOf("connection_id" to connectionId), + ), + ), + ) + check(response.str("status") == "removed" && response.str("connection_id") == connectionId) + Unit + } + + override suspend fun tools( + slug: String, + refresh: Boolean, + ) = guarded { + require(ConnectorRepository.isValidSlug(slug)) + val params = + buildMap { + put("slug", slug) + if (refresh) put("refresh", true) + } + obj(request(WsMethods.CONNECTORS_TOOLS, accountParams(params)))["tools"]!!.jsonArray.map { e -> + val r = e.jsonObject + ConnectorTool( + r.str("slug"), + r.str("name"), + r.str("description"), + r.str("facet"), + r.bool("deprecated"), + r.stringList("hints"), + ) + } + } + + override suspend fun policy() = + guarded { + val root = obj(request(WsMethods.CONNECTORS_POLICY_GET, accountParams())) + val layers = root.array("layers").map { it.jsonObject } + val member = layers.firstOrNull { it.str("kind") == "member" } + parsePolicy(root["effective"]!!.jsonObject).copy( + member = member?.let { parsePolicy(it["body"]!!.jsonObject, it.str("revision")) }, + inherited = + layers.filter { it.str("kind") != "member" }.map { + parsePolicy(it["body"]!!.jsonObject, it.str("revision")) + }, + ) + } + + override suspend fun setConnectorEnabled( + slug: String, + enabled: Boolean, + expectedRevision: String, + ) = setPolicy( + expectedRevision, + mapOf( + "type" to "connector", + "connector" to slug, + "enabled" to enabled, + ), + ) + + override suspend fun setDisabledTools( + slug: String, + disabled: List, + expectedRevision: String, + ) = setPolicy( + expectedRevision, + mapOf( + "type" to "tools", + "connector" to slug, + "disabled_tools" to disabled, + ), + ) + + private suspend fun setPolicy( + expectedRevision: String, + change: Map, + ) = guarded { + val scope = AuthManager.currentDataScope() + require(ConnectorRepository.isValidSlug(change["connector"] as? String)) + require(expectedRevision.isNotBlank()) + check(scope == AuthManager.currentDataScope()) { "Account scope changed" } + val result = + obj( + request( + WsMethods.CONNECTORS_POLICY_SET, + accountParams( + mapOf( + "change" to change, + "expected_revision" to expectedRevision, + ), + ), + ), + ) + check(scope == AuthManager.currentDataScope()) { "Account scope changed" } + when (val refreshed = policy()) { + is AccountConnectorResult.Success -> refreshed.value + is AccountConnectorResult.Failure -> parsePolicy(result["effective"]!!.jsonObject) + } + } + + private fun parsePolicy( + r: JsonObject, + revision: String = r.str("revision"), + ) = ConnectorPolicy( + revision, + r.str("mode"), + r.stringList("connectors"), + r.stringList("disabled_connectors"), + (r["tools"] as? JsonObject)?.mapValues { (_, v) -> + (v as? JsonArray)?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + } + ?: emptyMap(), + enabledTags = + (r["tags"] as? JsonObject) + ?.let { + (it["enable"] as? JsonArray)?.mapNotNull { tag -> + tag.jsonPrimitive.contentOrNull + } + }.orEmpty(), + disabledTags = + (r["tags"] as? JsonObject) + ?.let { + (it["disable"] as? JsonArray)?.mapNotNull { tag -> + tag.jsonPrimitive.contentOrNull + } + }.orEmpty(), + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt index 088c1e4e..04e490b5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt @@ -39,6 +39,7 @@ object ConnectionOperationParser { settled = payload["settled"] as? Boolean ?: false, settledBy = payload["settled_by"] as? String, targets = targets, + accountOwned = (payload["owner"] as? Map<*, *>)?.get("type") == "account", ) } 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 cdbf2e51..9dbe8cb5 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 @@ -104,12 +104,19 @@ object WsMethods { const val CONNECTION_RESPOND = "connection.respond" const val CONNECTORS_OPERATION_WAKE = "connectors.operation.wake" + const val CONNECTORS_OPERATION_STATUS = "connectors.operation.status" /** List available third-party connectors and authorization state for a session. */ const val CONNECTORS_LIST = "connectors.list" /** Initiate or restart authorization flow for one or more connectors. */ const val CONNECTORS_CONNECT = "connectors.connect" + const val CONNECTORS_CATALOG = "connectors.catalog" + const val CONNECTORS_ACCOUNTS = "connectors.accounts" + const val CONNECTORS_ACCOUNTS_REMOVE = "connectors.accounts.remove" + const val CONNECTORS_TOOLS = "connectors.tools" + const val CONNECTORS_POLICY_GET = "connectors.policy.get" + const val CONNECTORS_POLICY_SET = "connectors.policy.set" // ── Plugins ─────────────────────────────────────────────────────────── const val PLUGINS_MANAGE = "plugins.manage" @@ -165,6 +172,12 @@ object WsMethods { SESSION_CREATE, // session.create SESSION_LIST, // session.list MODEL_OPTIONS, // model.options: @_profile_scoped + CONNECTORS_CATALOG, + CONNECTORS_ACCOUNTS, + CONNECTORS_ACCOUNTS_REMOVE, + CONNECTORS_TOOLS, + CONNECTORS_POLICY_GET, + CONNECTORS_POLICY_SET, SESSION_RESUME, // session.resume SESSION_DELETE, // session.delete SESSION_STATUS, // session.status diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt index 0166a881..14ab2ba6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt @@ -60,7 +60,10 @@ internal class ConnectionResumeCheckpoint( /** Session-scoped authoritative reducer for backend-owned connector operations. */ class ChatConnectionOperationDelegate( private val requester: ConnectionOperationRequester, + private val accountOwned: Boolean, ) { + constructor(requester: ConnectionOperationRequester) : this(requester, false) + private val _state = MutableStateFlow(ConnectionOperationUiState()) val state: StateFlow = _state.asStateFlow() private var sessionId: String? = null @@ -170,7 +173,7 @@ class ChatConnectionOperationDelegate( method = WsMethods.CONNECTION_RESPOND, params = mapOf( - "owner" to ConnectorRepository.sessionOwner(requireSessionId()), + "owner" to ownerParams(), "op_id" to snapshot.opId, "result" to mapOf("targets" to listOf(answer)), ), @@ -183,7 +186,7 @@ class ChatConnectionOperationDelegate( method = WsMethods.CONNECTION_RESPOND, params = mapOf( - "owner" to ConnectorRepository.sessionOwner(requireSessionId()), + "owner" to ownerParams(), "op_id" to snapshot.opId, "result" to mapOf("settled_by" to "continue"), ), @@ -196,7 +199,7 @@ class ChatConnectionOperationDelegate( val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return dispatch( method = WsMethods.CONNECTORS_OPERATION_WAKE, - params = mapOf("owner" to ConnectorRepository.sessionOwner(requireSessionId()), "op_id" to snapshot.opId), + params = mapOf("owner" to ownerParams(), "op_id" to snapshot.opId), clearOnSuccess = true, unknownOperationSettles = true, ) @@ -237,7 +240,7 @@ class ChatConnectionOperationDelegate( @Synchronized private fun begin(action: ConnectionPendingAction): ConnectionOperationSnapshot? { val current = _state.value.operation ?: return null - if (sessionId.isNullOrBlank() || + if ((!accountOwned && sessionId.isNullOrBlank()) || _state.value.pendingAction != null || current.opId != action.opId || current.seq != action.observedSeq @@ -259,9 +262,15 @@ class ChatConnectionOperationDelegate( } private fun matchesSession(snapshot: ConnectionOperationSnapshot): Boolean = - !sessionId.isNullOrBlank() && snapshot.sessionId == sessionId + if (accountOwned) { + snapshot.accountOwned + } else { + !snapshot.accountOwned && !sessionId.isNullOrBlank() && + snapshot.sessionId == sessionId + } - private fun requireSessionId(): String = checkNotNull(sessionId?.takeIf { it.isNotBlank() }) + private fun ownerParams(): Map = + if (accountOwned) mapOf("type" to "account") else ConnectorRepository.sessionOwner(checkNotNull(sessionId)) private fun currentOp(): String = _state.value.operation 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 3eb0c605..8e09d120 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 @@ -179,7 +179,7 @@ internal fun ToolBubble( .padding(horizontal = 10.dp, vertical = 6.dp), ) { // ── Header row: icon + tool name ── - HeaderRow(message, config, contentColor, statusColors) + HeaderRow(message, config, contentColor, statusColors, view?.serverDisplayName) // ── Tool progress preview (tool.progress) ── if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) { @@ -528,6 +528,7 @@ private fun HeaderRow( config: ToolDisplayConfig, contentColor: Color, statusColors: HermesStatusColors, + displayName: String? = null, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -562,7 +563,7 @@ private fun HeaderRow( } Text( - text = message.toolName ?: stringResource(R.string.chat_tool_fallback), + text = displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback), style = MaterialTheme.typography.labelMedium.copy( color = contentColor, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt index 24638217..7e52589a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.ui.chat.tool.ToolView import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -42,7 +43,15 @@ fun parseToolOutput( // Extract args sub-object if present (new tool.complete format) and // the result sub-object, falling back to the top-level payload (old // format where result fields sat at the root). - val args = element["args"] as? JsonObject + val rawArgs = element["args"] as? JsonObject + // Gateway labels are envelope metadata; never interpret a real args.labels value as display text. + val labels = element["labels"] as? JsonArray + val args = + if (labels != null) { + JsonObject(rawArgs.orEmpty() + ("hermes_tool_labels" to labels)) + } else { + rawArgs + } val result = element["result"] as? JsonObject ?: element ToolViewBuilder.build( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt index aed5a017..430e777b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt @@ -64,4 +64,5 @@ data class ToolView( val searchQuery: String? = null, /** Detected error text (destructive styling in the view). */ val error: String? = null, + val serverDisplayName: String? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index 8666af00..f1807d13 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -1,7 +1,10 @@ package com.m57.hermescontrol.ui.chat.tool +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull /** * The tool-display engine's orchestrator — a port of the desktop app's @@ -48,12 +51,14 @@ object ToolViewBuilder { ToolStatusResolver.errorText(call, isError) } + val serverTitle = serverLabelTitle(call.args) val title = - when { - running -> renderer.pendingTitle(call) - error.isNotEmpty() -> renderer.errorTitle(call) - else -> renderer.doneTitle(call) - } ?: ToolJson.titleForTool(toolName) + serverTitle + ?: when { + running -> renderer.pendingTitle(call) + error.isNotEmpty() -> renderer.errorTitle(call) + else -> renderer.doneTitle(call) + } ?: ToolJson.titleForTool(toolName) val subtitle = if (error.isNotEmpty()) { @@ -103,6 +108,7 @@ object ToolViewBuilder { return ToolView( status = status, title = title, + serverDisplayName = serverTitle, subtitle = subtitle, detail = clampedDetail, detailLabel = extras.detailLabel, @@ -125,6 +131,20 @@ object ToolViewBuilder { ) } + /** #1288: server display metadata, not the real tool's `labels` argument. */ + internal fun serverLabelTitle(args: JsonObject?): String? = + (args?.get("hermes_tool_labels") as? JsonArray) + ?.mapNotNull { entry -> + val row = entry as? JsonObject ?: return@mapNotNull null + val text = + (row["text"] as? JsonPrimitive)?.contentOrNull?.takeIf(String::isNotBlank) + ?: return@mapNotNull null + val app = (row["app"] as? JsonPrimitive)?.contentOrNull.orEmpty() + if (app.isNotBlank() && !text.contains(app, ignoreCase = true)) "$app · $text" else text + }?.distinct() + ?.joinToString(" · ") + ?.takeIf(String::isNotBlank) + /** Subtitle fallback: first line of the heuristic summary, then contexts. */ private fun genericSubtitle(call: ToolCall): String { val summary = ToolResultSummary.formatToolResultSummary(call.rawResult) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsScreen.kt new file mode 100644 index 00000000..77352cf9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsScreen.kt @@ -0,0 +1,278 @@ +package com.m57.hermescontrol.ui.connectors + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ExternalActivityLifecycleGuard +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.ConnectorAccount +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.notification.NotificationHelper +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState +import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.util.ConnectorUrlValidator + +@Composable +fun AccountConnectorsScreen( + onOpenDrawer: () -> Unit, + vm: AccountConnectorsViewModel = viewModel { AccountConnectorsViewModel() }, +) { + val state by vm.state.collectAsStateWithLifecycle() + val connection by vm.connectionState.collectAsStateWithLifecycle() + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val spacing = LocalSpacing.current + var pendingRemoval by remember(state.scope) { mutableStateOf(null) } + var browserOperation by remember(state.scope) { mutableStateOf(null) } + var departed by remember(state.scope) { mutableStateOf(false) } + DisposableEffect(lifecycleOwner, state.scope) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE && browserOperation != null) departed = true + if (event == Lifecycle.Event.ON_RESUME && departed) { + val opId = browserOperation + browserOperation = null + departed = false + ExternalActivityLifecycleGuard.externalActivityReturned() + if (opId != null) vm.onBrowserReturn(opId) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + if (browserOperation != null) ExternalActivityLifecycleGuard.externalActivityReturned() + } + } + HermesScaffold( + title = { Text(stringResource(R.string.account_connectors_title)) }, + navigationIcon = NavIcon.Menu(onOpenDrawer), + onRefresh = vm::refresh, + ) { + when { + state.loading -> { + LoadingState() + } + + state.error != null && state.catalog.isEmpty() && state.accounts.isEmpty() -> { + ErrorState(message = state.error.orEmpty(), onRetry = vm::refresh) + } + + state.catalog.isEmpty() && state.accounts.isEmpty() -> { + EmptyState(title = stringResource(R.string.account_connectors_no_accounts)) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.md), + ) { + state.error?.let { error -> item { Text(error, color = MaterialTheme.colorScheme.error) } } + items(state.catalog, key = { "catalog-${it.slug}" }) { connector -> + val current = state.connectorStates.firstOrNull { it.connector == connector.slug } + val policy = state.policy + val enabled = policy?.connectorEnabled(connector.slug) ?: current?.enabled + val locked = policy?.inherited?.any { !it.connectorEnabled(connector.slug) } == true + val editable = policy?.member != null && !state.busy && !locked + Card(Modifier.fillMaxWidth()) { + Column( + Modifier.padding(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Text(connector.name, style = MaterialTheme.typography.titleMedium) + if (connector.description.isNotBlank()) Text(connector.description) + Text( + current?.connectionStatus + ?: stringResource(R.string.account_connectors_not_connected), + ) + // Stack actions so large fonts and narrow phones never hide a control. + Button( + onClick = { vm.connect(connector.slug, reconnect = current?.isConnected == true) }, + enabled = !state.busy && connection.operation == null, + ) { + Text( + stringResource( + if (current?.isConnected == + true + ) { + R.string.account_connectors_reconnect + } else { + R.string.account_connectors_connect + }, + ), + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = enabled == true, + enabled = editable, + onCheckedChange = { vm.setEnabled(connector.slug, it) }, + ) + Text(stringResource(R.string.account_connectors_enabled)) + } + if (locked || + policy?.member == null + ) { + Text(stringResource(R.string.account_connectors_policy_locked)) + } + OutlinedButton( + onClick = { vm.loadTools(connector.slug) }, + enabled = connector.slug !in state.toolsLoading, + ) { Text(stringResource(R.string.account_connectors_tools)) } + state.tools[connector.slug]?.let { tools -> + if (tools.isEmpty()) Text(stringResource(R.string.account_connectors_no_tools)) + tools.forEach { tool -> + val disabled = + tool.slug in + policy + ?.member + ?.tools + ?.get(connector.slug) + .orEmpty() + val toolLocked = + policy?.inherited?.any { !it.toolEnabled(connector.slug, tool) } == true + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = enabled == true && !disabled && !toolLocked, + enabled = editable && enabled == true && !toolLocked, + onCheckedChange = { checked -> + val updated = + state.policy + ?.member + ?.tools + ?.get( + connector.slug, + ).orEmpty() + .toMutableSet() + if (checked) updated.remove(tool.slug) else updated.add(tool.slug) + vm.setDisabledTools(connector.slug, updated.toList()) + }, + ) + Column(Modifier.weight(1f)) { + Text(tool.name) + if (toolLocked) { + Text( + stringResource(R.string.account_connectors_policy_locked), + ) + } + Text(tool.description, style = MaterialTheme.typography.bodySmall) + Text(tool.facet, style = MaterialTheme.typography.labelSmall) + } + } + } + } + } + } + } + item { + Text( + stringResource(R.string.account_connectors_accounts), + style = MaterialTheme.typography.titleMedium, + ) + } + items(state.accounts, key = { "account-${it.connectionId}" }) { account -> + Card(Modifier.fillMaxWidth()) { + Column( + Modifier.padding(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Text(account.label, style = MaterialTheme.typography.titleMedium) + Text( + state.catalog.firstOrNull { it.slug == account.connector }?.name + ?: account.connector, + ) + Text(account.status) + account.statusReason?.let { Text(it) } + account.alias?.let { Text(it) } + OutlinedButton(onClick = { pendingRemoval = account }, enabled = !state.busy) { + Text(stringResource(R.string.account_connectors_remove)) + } + } + } + } + if (state.accounts.isEmpty()) item { Text(stringResource(R.string.account_connectors_no_accounts)) } + } + } + } + } + ConnectionSetupSheet( + state = connection, + onRespond = vm::respond, + onContinue = vm::continueOperation, + onDismiss = vm::continueOperation, + onOpenBrowser = { opId, url -> + if (ConnectorUrlValidator.isValidHttpsUrl(url)) { + try { + ExternalActivityLifecycleGuard.launchExternalActivity( + acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, + releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease, + prepareForBackground = { NotificationHelper.start(context) }, + cleanupAfterLaunchFailure = { NotificationHelper.stop(context) }, + ) { + browserOperation = opId + departed = false + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + } catch (_: Exception) { + browserOperation = null + ExternalActivityLifecycleGuard.externalActivityReturned() + vm.browserLaunchFailed() + } + } + }, + ) + pendingRemoval?.let { account -> + AlertDialog( + onDismissRequest = { pendingRemoval = null }, + title = { Text(stringResource(R.string.account_connectors_remove_confirm_title)) }, + text = { Text(stringResource(R.string.account_connectors_remove_confirm_message, account.label)) }, + confirmButton = { + TextButton(onClick = { + vm.remove(account.connectionId) + pendingRemoval = null + }, enabled = !state.busy) { Text(stringResource(R.string.account_connectors_remove)) } + }, + dismissButton = { + TextButton( + onClick = { pendingRemoval = null }, + ) { Text(stringResource(R.string.common_cancel)) } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt new file mode 100644 index 00000000..aacfe702 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt @@ -0,0 +1,268 @@ +package com.m57.hermescontrol.ui.connectors + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope +import com.m57.hermescontrol.data.model.AccountConnectorResult +import com.m57.hermescontrol.data.model.ConnectorAccount +import com.m57.hermescontrol.data.model.ConnectorCatalogEntry +import com.m57.hermescontrol.data.model.ConnectorItem +import com.m57.hermescontrol.data.model.ConnectorPolicy +import com.m57.hermescontrol.data.model.ConnectorTool +import com.m57.hermescontrol.data.ws.AccountConnectorRepository +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.ui.chat.ChatConnectionOperationDelegate +import com.m57.hermescontrol.ui.chat.ConnectionOperationRequester +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class AccountConnectorsState( + val loading: Boolean = true, + val error: String? = null, + val catalog: List = emptyList(), + val connectorStates: List = emptyList(), + val accounts: List = emptyList(), + val tools: Map> = emptyMap(), + val toolsLoading: Set = emptySet(), + val policy: ConnectorPolicy? = null, + val busy: Boolean = false, + val scope: DataScope? = null, +) + +class AccountConnectorsViewModel( + private val repository: AccountConnectorRepository = AccountConnectorRepository, + private val scopes: StateFlow = AuthManager.dataScopeFlow, + private val currentScope: () -> DataScope? = AuthManager::currentDataScope, + events: Flow = HermesWsClient.events, +) : ViewModel() { + private val _state = MutableStateFlow(AccountConnectorsState()) + val state: StateFlow = _state.asStateFlow() + private var generation = 0L + private var loadJob: Job? = null + private val scopedJobs = mutableListOf() + private val operation = + ChatConnectionOperationDelegate( + requester = ConnectionOperationRequester(repository::operationRequest), + accountOwned = true, + ) + val connectionState = operation.state + + init { + viewModelScope.launch { + scopes.collect { scope -> + generation++ + loadJob?.cancel() + scopedJobs.forEach(Job::cancel) + scopedJobs.clear() + operation.reset() + _state.value = AccountConnectorsState(scope = scope) + if (scope != null) load() else _state.update { it.copy(loading = false) } + } + } + viewModelScope.launch { + events.collect { event -> + if (event is WsEvent.GatewayReady) refresh() + val snapshot = (event as? WsEvent.ConnectionUpdate)?.snapshot ?: return@collect + // Never adopt an unsolicited account event: only our acknowledged operation belongs here. + if (isCurrent(generation) && snapshot.accountOwned && + snapshot.opId == + operation.state.value.operation + ?.opId && operation.acceptUpdate(snapshot) + ) { + if (snapshot.settled) load() + } + } + } + } + + private fun isCurrent(epoch: Long): Boolean = epoch == generation && _state.value.scope == currentScope() + + fun refresh() { + if (!_state.value.busy) { + load() + if (connectionState.value.operation != null) operationAction { } + } + } + + private fun load() { + loadJob?.cancel() + val epoch = generation + _state.update { it.copy(loading = true, error = null) } + loadJob = + viewModelScope.launch { + val list = async { repository.listConnectors() } + val catalog = async { repository.catalog() } + val accounts = async { repository.accounts() } + val policy = async { repository.policy() } + val listResult = list.await() + val catalogResult = catalog.await() + val accountsResult = accounts.await() + val policyResult = policy.await() + if (!isCurrent(epoch)) return@launch + _state.update { + it.copy( + loading = false, + error = + listResult.errorOrNull()?.message ?: catalogResult.errorMessage() + ?: accountsResult.errorMessage() ?: policyResult.errorMessage(), + catalog = catalogResult.valueOrNull().orEmpty(), + connectorStates = listResult.getOrNull().orEmpty(), + accounts = accountsResult.valueOrNull().orEmpty(), + policy = policyResult.valueOrNull(), + ) + } + } + } + + fun loadTools(slug: String) { + scopedJobs.removeAll { it.isCompleted } + if (slug in _state.value.toolsLoading || !isCurrent(generation)) return + val epoch = generation + _state.update { it.copy(toolsLoading = it.toolsLoading + slug) } + scopedJobs += + viewModelScope.launch { + val result = repository.tools(slug) + if (!isCurrent(epoch)) return@launch + _state.update { + it.copy( + tools = result.valueOrNull()?.let { tools -> it.tools + (slug to tools) } ?: it.tools, + toolsLoading = it.toolsLoading - slug, + error = result.errorMessage(), + ) + } + } + } + + fun connect( + slug: String, + reconnect: Boolean = false, + ) = mutate { + val epoch = generation + val result = repository.connect(listOf(slug), reconnect) + if (isCurrent(epoch)) { + result.valueOrNull()?.let { snapshot -> + operation.acceptRequest(snapshot) + if (snapshot.settled) load() + } + } + result.errorMessage() + } + + fun remove(connectionId: String) = + mutate { + val epoch = generation + val result = repository.remove(connectionId) + if (isCurrent(epoch) && result is AccountConnectorResult.Success) load() + result.errorMessage() + } + + fun setEnabled( + slug: String, + enabled: Boolean, + ) = updatePolicy { + repository.setConnectorEnabled( + slug, + enabled, + _state.value.policy + ?.member + ?.revision + .orEmpty(), + ) + } + + fun setDisabledTools( + slug: String, + disabled: List, + ) = updatePolicy { + repository.setDisabledTools( + slug, + disabled, + _state.value.policy + ?.member + ?.revision + .orEmpty(), + ) + } + + private fun updatePolicy(action: suspend () -> AccountConnectorResult) = + mutate { + val epoch = generation + val result = action() + if (isCurrent(epoch)) result.valueOrNull()?.let { policy -> _state.update { it.copy(policy = policy) } } + result.errorMessage() + } + + // Reserve synchronously, not inside launch: repeated taps must not start concurrent writes. + private fun mutate(action: suspend () -> String?) { + scopedJobs.removeAll { it.isCompleted } + if (_state.value.scope == null || _state.value.busy || _state.value.loading || !isCurrent(generation)) return + val epoch = generation + _state.update { it.copy(busy = true, error = null) } + scopedJobs += + viewModelScope.launch { + try { + if (!isCurrent(epoch)) return@launch + val error = action() + if (isCurrent(epoch)) _state.update { it.copy(busy = false, error = error) } + } catch (e: CancellationException) { + throw e + } finally { + if (isCurrent(epoch)) _state.update { it.copy(busy = false) } + } + } + } + + fun respond( + target: String, + env: Map, + approved: Boolean, + ) = operationAction { + operation.respond(target, env, approved) + } + + fun continueOperation() = operationAction { operation.continueOperation() } + + fun onBrowserReturn(opId: String) = operationAction { operation.wake(opId) } + + private fun operationAction(action: suspend () -> Unit) { + scopedJobs.removeAll { it.isCompleted } + if (!isCurrent(generation)) return + val epoch = generation + val opId = connectionState.value.operation?.opId ?: return + scopedJobs += + viewModelScope.launch { + action() + if (!isCurrent(epoch)) return@launch + val result = repository.operationStatus(opId) + if (!isCurrent(epoch)) return@launch + when (result) { + is AccountConnectorResult.Success -> { + operation.acceptUpdate(result.value) + if (result.value.settled) load() + } + + is AccountConnectorResult.Failure -> { + _state.update { it.copy(error = result.error.message) } + } + } + } + } + + fun browserLaunchFailed() { + _state.update { it.copy(error = "Unable to open the authorization browser.") } + } +} + +private fun AccountConnectorResult.valueOrNull(): T? = (this as? AccountConnectorResult.Success)?.value + +private fun AccountConnectorResult<*>.errorMessage(): String? = + (this as? AccountConnectorResult.Failure)?.error?.message diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index 6ea47d43..8669d16c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ListAlt import androidx.compose.material.icons.filled.ChatBubbleOutline +import androidx.compose.material.icons.filled.Extension import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Lock @@ -34,6 +35,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.AccountConnectorsScreen import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.SettingsAbout @@ -87,6 +89,12 @@ fun SettingsScreen( }, onClick = { NavigationController.navigateTo(SettingsConnection) }, ), + SettingsRow( + icon = Icons.Filled.Extension, + label = stringResource(R.string.screen_account_connectors), + summary = stringResource(R.string.account_connectors_summary), + onClick = { NavigationController.navigateTo(AccountConnectorsScreen) }, + ), SettingsRow( icon = Icons.Filled.Palette, label = stringResource(R.string.settings_sec_appearance), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52a2de07..d150e5ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,6 +87,7 @@ Cron System Settings + Connectors Profiles Bots Active Now @@ -248,6 +249,17 @@ Connection + Account connectors + Manage account authorizations and tool access without a chat session. + Authorized accounts + Connect + Tools + Enabled + Not connected + No authorized connector accounts. + Remove + Remove connector account? + Remove %1$s? You may need to authorize it again. Auth Token Stay connected in background Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery. @@ -1804,4 +1816,7 @@ The server did not provide capability details. Accept and update Session storage is damaged for %1$s. History may be incomplete; previously loaded sessions are kept. Restore or recover the database on the server, then refresh. + Reconnect + No tools available + Read-only policy restrictions diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt new file mode 100644 index 00000000..c13f06b0 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt @@ -0,0 +1,255 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.AccountConnectorResult +import com.m57.hermescontrol.data.model.ConnectorTool +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class AccountConnectorRepositoryTest { + @Before fun setup() { + mockkObject(AuthManager) + every { AuthManager.activeProfileId } returns MutableStateFlow("work") + every { AuthManager.currentDataScope() } returns null + } + + @After fun teardown() = unmockkAll() + + @Test fun `connect decodes operation snapshot and preserves authorization link without a session`() = + runTest { + val calls = mutableListOf>>() + val repo = + HermesAccountConnectorRepository { method, params -> + calls += method to params + operationFixture() + } + val result = repo.connect(listOf("drive"), true) as AccountConnectorResult.Success + assertTrue(result.value.accountOwned) + assertEquals("op-a", result.value.opId) + assertEquals( + "https://example.com/authorize", + result.value.targets + .single() + .safeConnectUrl, + ) + assertEquals( + mapOf( + "profile" to "work", + "owner" to mapOf("type" to "account"), + "connectors" to listOf("drive"), + "reconnect" to true, + ), + calls.single().second, + ) + assertEquals(WsMethods.CONNECTORS_CONNECT, calls.single().first) + } + + @Test fun `account operation respond status and wake retain profile and owner`() = + runTest { + val calls = mutableListOf>>() + val repo = + HermesAccountConnectorRepository { method, params -> + calls += method to params + operationFixture() + } + repo.operationStatus("op-a") + repo.operationRequest( + WsMethods.CONNECTION_RESPOND, + mapOf( + "op_id" to "op-a", + "result" to mapOf("settled_by" to "continue"), + ), + ) + repo.operationRequest(WsMethods.CONNECTORS_OPERATION_WAKE, mapOf("op_id" to "op-a")) + assertEquals( + listOf( + WsMethods.CONNECTORS_OPERATION_STATUS, + WsMethods.CONNECTION_RESPOND, + WsMethods.CONNECTORS_OPERATION_WAKE, + ), + calls.map { + it.first + }, + ) + calls.forEach { (_, params) -> + assertEquals("work", params["profile"]) + assertEquals(mapOf("type" to "account"), params["owner"]) + assertEquals("op-a", params["op_id"]) + assertFalse("session_id" in params) + } + } + + @Test fun `catalog accounts tools and removal use native account endpoints`() = + runTest { + val calls = mutableListOf>>() + val repo = + HermesAccountConnectorRepository { method, params -> + calls += method to params + when (method) { + WsMethods.CONNECTORS_CATALOG -> { + mapOf( + "connectors" to + listOf( + mapOf( + "slug" to "drive", + "name" to "Google Drive", + "description" to "Files", + "category" to "storage", + ), + ), + ) + } + + WsMethods.CONNECTORS_ACCOUNTS -> { + mapOf( + "accounts" to + listOf( + mapOf( + "connection_id" to "conn-1", + "connector" to "drive", + "label" to "Work account", + "status" to "active", + "active" to true, + "alias" to null, + ), + ), + ) + } + + WsMethods.CONNECTORS_TOOLS -> { + mapOf( + "tools" to + listOf( + mapOf( + "slug" to "delete", + "name" to "Delete file", + "hints" to listOf("destructive"), + ), + ), + ) + } + + WsMethods.CONNECTORS_ACCOUNTS_REMOVE -> { + mapOf( + "connection_id" to "conn-1", + "connector" to "drive", + "status" to "removed", + ) + } + + else -> { + error(method) + } + } + } + assertEquals("Google Drive", (repo.catalog() as AccountConnectorResult.Success).value.single().name) + assertEquals("Work account", (repo.accounts() as AccountConnectorResult.Success).value.single().label) + assertEquals( + "Delete file", + (repo.tools("drive", true) as AccountConnectorResult.Success).value.single().name, + ) + assertTrue(repo.remove("conn-1") is AccountConnectorResult.Success) + assertTrue(calls.all { "owner" !in it.second && it.second["profile"] == "work" }) + assertEquals(mapOf("profile" to "work", "slug" to "drive", "refresh" to true), calls[2].second) + assertEquals(mapOf("profile" to "work", "connection_id" to "conn-1"), calls[3].second) + } + + @Test fun `policy keeps member revision separate from effective and respects inherited rules`() = + runTest { + val repo = HermesAccountConnectorRepository { _, _ -> policyFixture() } + val policy = (repo.policy() as AccountConnectorResult.Success).value + assertEquals("effective-revision", policy.revision) + assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", policy.member?.revision) + assertEquals(listOf("mine"), policy.member?.tools?.get("drive")) + val destructive = ConnectorTool("delete", "Delete", "", "destructive", false, listOf("destructive")) + assertFalse(policy.inherited.single().toolEnabled("drive", destructive)) + assertFalse(policy.copy(mode = "deny-all").connectorEnabled("drive")) + assertFalse(policy.copy(mode = "future-mode").connectorEnabled("drive")) + } + + @Test fun `writes use observed member revision not effective revision or fresh overwritten version`() = + runTest { + val calls = mutableListOf>>() + val repo = + HermesAccountConnectorRepository { method, params -> + calls += method to params + if (method == WsMethods.CONNECTORS_POLICY_GET) { + policyFixture() + } else { + mapOf( + "revision" to "new-member-revision", + "effective" to mapOf("mode" to "deny-all", "revision" to "effective-revision"), + ) + } + } + assertTrue( + repo.setDisabledTools( + "drive", + listOf("delete"), + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + ) is AccountConnectorResult.Success, + ) + assertEquals(WsMethods.CONNECTORS_POLICY_SET, calls.first().first) + assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", calls.first().second["expected_revision"]) + assertEquals( + mapOf("type" to "tools", "connector" to "drive", "disabled_tools" to listOf("delete")), + calls.first().second["change"], + ) + } + + @Test fun `malformed authorization does not look like a successful connect`() = + runTest { + val repo = HermesAccountConnectorRepository { _, _ -> mapOf("status" to "initiated") } + assertTrue(repo.connect(listOf("drive")) is AccountConnectorResult.Failure) + } + + private fun operationFixture() = + mapOf( + "op_id" to "op-a", + "seq" to 1, + "deadline_at" to 2000000000.0, + "settled" to false, + "targets" to + listOf( + mapOf( + "name" to "drive", + "kind" to "connector", + "action" to "authorize", + "state" to "initiated", + "connect_url" to "https://example.com/authorize", + ), + ), + ) + + private fun policyFixture() = + mapOf( + "effective" to + mapOf( + "mode" to "deny", + "revision" to "effective-revision", + "disabled_connectors" to emptyList(), + ), + "layers" to + listOf( + mapOf( + "kind" to "member", + "revision" to "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "body" to mapOf("mode" to "deny", "tools" to mapOf("drive" to listOf("mine"))), + ), + mapOf( + "kind" to "org", + "revision" to "org-revision", + "body" to mapOf("mode" to "deny", "tags" to mapOf("disable" to listOf("destructive"))), + ), + ), + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ConnectorToolLabelsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ConnectorToolLabelsTest.kt new file mode 100644 index 00000000..6970008a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ConnectorToolLabelsTest.kt @@ -0,0 +1,40 @@ +package com.m57.hermescontrol.ui.chat.tool + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectorToolLabelsTest { + @Test fun `gateway envelope labels reach the visible header without replacing real tool arguments`() { + val content = """{ + "name":"tool_call", + "args":{"labels":["bug"]}, + "labels":[{"app":"GitHub","text":"Create issue"}], + "result":{} + }""" + val view = + com.m57.hermescontrol.ui.chat + .parseToolOutput(content, "tool_call", false) + assertEquals("GitHub · Create issue", view?.serverDisplayName) + assertEquals("GitHub · Create issue", view?.title) + } + + @Test fun `server connector labels title both live and restored tool calls`() { + val args = + Json.parseToJsonElement( + """{"hermes_tool_labels":[{"app":"Google Drive","text":"List files","kind":"connector"}]}""", + ) + val running = ToolViewBuilder.build("tool_call", args, null, running = true) + val restored = ToolViewBuilder.build("tool_call", args, Json.parseToJsonElement("{}")) + assertEquals("Google Drive · List files", running.title) + assertEquals(running.title, restored.title) + } + + @Test fun `malformed metadata and actual labels argument keep fallback title`() { + val args = Json.parseToJsonElement("""{"labels":["bug"],"hermes_tool_labels":[null,42,{"app":"GitHub"}]}""") + val view = ToolViewBuilder.build("tool_call", args, null) + assertTrue(view.title.isNotBlank()) + assertEquals(ToolViewBuilder.build("tool_call", null, null).title, view.title) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt new file mode 100644 index 00000000..e6ff4c9e --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt @@ -0,0 +1,207 @@ +package com.m57.hermescontrol.ui.connectors + +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.DataScope +import com.m57.hermescontrol.data.model.AccountConnectorResult +import com.m57.hermescontrol.data.model.ConnectorCatalogEntry +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorListResult +import com.m57.hermescontrol.data.model.ConnectorPolicy +import com.m57.hermescontrol.data.ws.AccountConnectorRepository +import com.m57.hermescontrol.data.ws.ConnectionOperationParser +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.data.ws.WsMethods +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AccountConnectorsViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val repo = mockk() + private val scopes = MutableStateFlow(DataScope("server", "http://host", "work", 1)) + private val events = MutableSharedFlow() + private lateinit var vm: AccountConnectorsViewModel + private val policy = ConnectorPolicy("effective", "unrestricted", emptyList(), emptyList(), emptyMap()) + + @Before fun setup() { + Dispatchers.setMain(dispatcher) + coEvery { repo.listConnectors() } returns ConnectorListResult.Success(true, emptyList()) + coEvery { repo.catalog() } returns + AccountConnectorResult.Success(listOf(ConnectorCatalogEntry("drive", "Google Drive", "Files", "storage"))) + coEvery { repo.accounts() } returns AccountConnectorResult.Success(emptyList()) + coEvery { repo.policy() } returns + AccountConnectorResult.Success(policy.copy(member = policy.copy(revision = "member"))) + coEvery { repo.operationStatus("op-a") } returns AccountConnectorResult.Success(snapshot()) + vm = AccountConnectorsViewModel(repo, scopes, { scopes.value }, events) + } + + @After fun teardown() { + vm.viewModelScope.cancel() + Dispatchers.resetMain() + } + + @Test fun `no session required and connect retains the browser operation`() = + runTest(dispatcher) { + advanceUntilIdle() + assertFalse(vm.state.value.loading) + assertEquals( + "Google Drive", + vm.state.value.catalog + .single() + .name, + ) + coEvery { repo.connect(listOf("drive"), false) } returns AccountConnectorResult.Success(snapshot()) + vm.connect("drive") + vm.connect("drive") + advanceUntilIdle() + coVerify(exactly = 1) { repo.connect(listOf("drive"), false) } + assertEquals( + "https://example.com/auth", + vm.connectionState.value.operation + ?.targets + ?.single() + ?.safeConnectUrl, + ) + } + + @Test fun `connect and remove errors remain visible rather than cleared by reload`() = + runTest(dispatcher) { + advanceUntilIdle() + coEvery { repo.connect(any(), any()) } returns + AccountConnectorResult.Failure(ConnectorError.RequestFailed("authorization failed")) + vm.connect("drive") + advanceUntilIdle() + assertEquals("authorization failed", vm.state.value.error) + coEvery { repo.remove("c1") } returns + AccountConnectorResult.Failure(ConnectorError.RequestFailed("remove failed")) + vm.remove("c1") + advanceUntilIdle() + assertEquals("remove failed", vm.state.value.error) + assertFalse(vm.state.value.busy) + } + + @Test fun `profile switch clears operation and rejects prior account update`() = + runTest(dispatcher) { + advanceUntilIdle() + coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot()) + vm.connect("drive") + advanceUntilIdle() + scopes.value = scopes.value?.copy(activeProfileId = "other") + advanceUntilIdle() + events.emit(WsEvent.ConnectionUpdate(snapshot().copy(seq = 2))) + advanceUntilIdle() + assertNull(vm.connectionState.value.operation) + assertEquals( + "other", + vm.state.value.scope + ?.activeProfileId, + ) + } + + @Test fun `late tool response cannot populate a new account scope`() = + runTest(dispatcher) { + advanceUntilIdle() + val pending = + CompletableDeferred>>() + coEvery { repo.tools("drive", false) } coAnswers { pending.await() } + vm.loadTools("drive") + dispatcher.scheduler.runCurrent() + scopes.value = scopes.value?.copy(inMemoryAuthGeneration = 2) + advanceUntilIdle() + pending.complete(AccountConnectorResult.Success(emptyList())) + advanceUntilIdle() + assertTrue( + vm.state.value.tools + .isEmpty(), + ) + assertTrue( + vm.state.value.toolsLoading + .isEmpty(), + ) + } + + @Test fun `continue answers account operation and settlement closes it`() = + runTest(dispatcher) { + advanceUntilIdle() + coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot()) + coEvery { repo.operationRequest(any(), any()) } returns mapOf("status" to "ok") + coEvery { repo.operationStatus("op-a") } returns + AccountConnectorResult.Success(snapshot().copy(seq = 2, settled = true)) + vm.connect("drive") + advanceUntilIdle() + vm.continueOperation() + advanceUntilIdle() + coVerify { + repo.operationRequest( + WsMethods.CONNECTION_RESPOND, + mapOf( + "owner" to mapOf("type" to "account"), + "op_id" to "op-a", + "result" to mapOf("settled_by" to "continue"), + ), + ) + } + assertNull(vm.connectionState.value.operation) + } + + @Test fun `session update never changes account operation`() = + runTest(dispatcher) { + advanceUntilIdle() + coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot()) + vm.connect("drive") + advanceUntilIdle() + events.emit( + WsEvent.ConnectionUpdate( + snapshot().copy(seq = 3, settled = true, accountOwned = false, sessionId = "chat"), + ), + ) + advanceUntilIdle() + assertEquals( + 1L, + vm.connectionState.value.operation + ?.seq, + ) + } + + private fun snapshot() = + checkNotNull( + ConnectionOperationParser.parse( + mapOf( + "owner" to mapOf("type" to "account"), + "op_id" to "op-a", + "seq" to 1, + "deadline_at" to 2000000000.0, + "settled" to false, + "targets" to + listOf( + mapOf( + "name" to "drive", + "kind" to "connector", + "action" to "authorize", + "state" to "initiated", + "connect_url" to "https://example.com/auth", + ), + ), + ), + ), + ) +} From ef54804b724ae165321ee56ce241b7aa87847aae Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:56:43 +0300 Subject: [PATCH 028/107] feat(#1289): lightweight /fork via session.branch_whole (#1303) --- .../m57/hermescontrol/data/ws/WsMethods.kt | 1 + .../hermescontrol/ui/chat/ChatViewModel.kt | 45 +++++- .../ui/chat/SlashCommandDispatcher.kt | 3 +- .../data/ws/WsProfileParamsTest.kt | 2 +- .../ui/chat/ChatViewModelTest.kt | 151 +++++++++++++++++- 5 files changed, 186 insertions(+), 16 deletions(-) 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 9dbe8cb5..a6de9886 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 @@ -15,6 +15,7 @@ object WsMethods { const val SESSION_DELETE = "session.delete" const val SESSION_TITLE = "session.title" const val SESSION_BRANCH = "session.branch" + const val SESSION_BRANCH_WHOLE = "session.branch_whole" /** Replay recorded events newer than client's last-seen seq for a session. */ const val SESSION_EVENTS_SINCE = "session.events.since" 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 b4d9ba7e..ff2e727a 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 @@ -483,7 +483,13 @@ class ChatViewModel( val originalPending: PendingSend? = null, ) + private data class PendingBranchRequest( + val generation: Long, + val params: Map, + ) + private val sessionRequestById = ConcurrentHashMap() + private val branchWholeRequests = ConcurrentHashMap() private val outgoingRequestById = ConcurrentHashMap() private val hardInterruptRequestById = ConcurrentHashMap() private val queuedStagingIds = ConcurrentHashMap.newKeySet() @@ -1663,6 +1669,7 @@ class ChatViewModel( ) { val method = idToMethod.remove(id) ?: return val request = sessionRequestById.remove(id) + branchWholeRequests.remove(id) if (request != null && isStaleSessionRequest(request)) { outgoingRequestById.remove(id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) } hardInterruptRequestById.remove(id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) } @@ -1749,7 +1756,9 @@ class ChatViewModel( } } - WsMethods.SESSION_BRANCH -> { + WsMethods.SESSION_BRANCH, + WsMethods.SESSION_BRANCH_WHOLE, + -> { val resultMap = result as? Map ?: return // The result carries BOTH ids: `session_id` is the runtime // registry id, `stored_session_id` is the DB key. currentSessionId @@ -2086,6 +2095,7 @@ class ChatViewModel( ) { val method = idToMethod.remove(id) ?: return val request = sessionRequestById.remove(id) + val pendingBranch = branchWholeRequests.remove(id) if (request != null && isStaleSessionRequest(request)) { outgoingRequestById.remove(id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) } hardInterruptRequestById.remove(id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) } @@ -2165,6 +2175,26 @@ class ChatViewModel( } } + if (method == WsMethods.SESSION_BRANCH_WHOLE) { + val code = + (error as? JsonRpcError)?.code + ?: (error as? Map<*, *>)?.get("code") as? Int + if (code == -32601 && pendingBranch != null && pendingBranch.generation == sessionGeneration) { + val params = pendingBranch.params + val generation = pendingBranch.generation + viewModelScope.launch(ioDispatcher) { + wsClient.send( + WsMethods.SESSION_BRANCH, + params, + onSent = { branchId -> + trackSessionRequest(branchId, WsMethods.SESSION_BRANCH, generation) + }, + ) + } + return + } + } + // Surface error in UI (these are server-pushed RpcError for // fire-and-forget RPCs — awaited RPCs handle their own failure // via the HermesWsClient.request() deferred). @@ -3940,9 +3970,8 @@ class ChatViewModel( } /** - * Fork the active conversation via the session.branch WS RPC (issue #533). - * The backend already supports session.branch; the mobile previously had - * no client surface, so `/fork` fell through to command.dispatch and 4018'd. + * Fork the active conversation via the lightweight session.branch_whole WS RPC (issue #1289). + * Falls back to session.branch if the gateway answers -32601 (unknown method). * The optional arg becomes the new branch's title. */ private fun branchSession(command: String) { @@ -3957,9 +3986,12 @@ class ChatViewModel( val generation = sessionGeneration viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_BRANCH, + WsMethods.SESSION_BRANCH_WHOLE, params, - onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_BRANCH, generation) }, + onSent = { id -> + branchWholeRequests[id] = PendingBranchRequest(generation, params) + trackSessionRequest(id, WsMethods.SESSION_BRANCH_WHOLE, generation) + }, ) } } @@ -6006,6 +6038,7 @@ class ChatViewModel( private fun forgetRequest(id: String) { idToMethod.remove(id) sessionRequestById.remove(id) + branchWholeRequests.remove(id) } // ── Search ──────────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt index 31712655..b1e49b0a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -10,7 +10,8 @@ package com.m57.hermescontrol.ui.chat * `/fork` and `/model` are NOT sent via [SlashResult.RpcDispatch] (which maps * to the `command.dispatch` RPC — that RPC only knows quick/plugin/bundle/skill * commands and 4018s on everything else). They are real backend commands that - * get their own results: `/fork` goes via the `session.branch` RPC and `/model` + * get their own results: `/fork` goes via the `session.branch_whole` RPC (with + * fallback to `session.branch` on older gateways) and `/model` * via the `config.set` RPC (key="model" → gateway `_apply_model_switch`; the * TUI gateway's `prompt.submit` does NOT parse slash commands, so sending it as * a normal prompt makes the LLM treat it as text). `/update` is intercepted diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt index a1f7cd81..7a82e647 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt @@ -289,7 +289,7 @@ class WsProfileParamsTest { * home) without being listed here, this test fails — the set cannot drift. * * Methods that operate on an ALREADY-BOUND session (session.usage, - * session.interrupt, session.branch, session.redirect, + * session.interrupt, session.branch, session.branch_whole, session.redirect, * session.context_breakdown, prompt.submit, slash/command dispatch, * approval/clarify/secret/sudo responses, file attach, process.*, * subscription.*, commands_catalog, usage.bars) resolve via the 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 4048d080..a943144a 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 @@ -761,12 +761,12 @@ class ChatViewModelTest { id } - // /fork with an optional branch title. + // /fork with an optional branch title sends session.branch_whole. viewModel.sendMessage("/fork my-fork") advanceUntilIdle() - val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH } - assertNotNull("session.branch should be dispatched for /fork", branchSent) + val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } + assertNotNull("session.branch_whole should be dispatched for /fork", branchSent) assertEquals(sessionId, branchSent!!.second["session_id"]) assertEquals("my-fork", branchSent.second["name"]) } @@ -811,12 +811,147 @@ class ChatViewModelTest { viewModel.sendMessage("/fork") advanceUntilIdle() - val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH } - assertNotNull("session.branch should be dispatched for /fork", branchSent) + val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } + assertNotNull("session.branch_whole should be dispatched for /fork", branchSent) assertEquals(sessionId, branchSent!!.second["session_id"]) assertFalse("name param should be omitted when no title given", branchSent.second.containsKey("name")) } + @Test + fun testSlashCommand_fork_branchWholeSuccess_handledLikeSessionBranch() = + runTest { + val (viewModel, _) = createViewModelWithSession() + val captured = mutableListOf>>() + every { HermesWsClient.send(any(), any(), any()) } answers { + val id = "req-${captured.size + 1}" + captured.add(arg(0) to (arg>(1))) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + viewModel.sendMessage("/fork") + advanceUntilIdle() + + val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } + assertNotNull("session.branch_whole dispatched", branchWholeSent) + + mockEventsFlow.emit( + WsEvent.RpcResult( + "req-1", + mapOf( + "session_id" to "runtime-branch-whole", + "stored_session_id" to "stored-branch-whole", + "title" to "Whole Branch", + ), + ), + ) + advanceUntilIdle() + + assertEquals("runtime-branch-whole", ActiveSessionHolder.activeSessionId.value) + assertEquals("stored-branch-whole", viewModel.uiState.value.currentSessionId) + assertTrue( + "System message 'Session branched' should be added", + viewModel.uiState.value.messages + .any { it.content.contains("Session branched") }, + ) + // No fallback was sent + assertEquals(0, captured.count { it.first == WsMethods.SESSION_BRANCH }) + assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH_WHOLE }) + } + + @Test + fun testSlashCommand_fork_branchWholeError32601_retriesWithSessionBranch() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + val captured = mutableListOf>>() + every { HermesWsClient.send(any(), any(), any()) } answers { + val id = "req-${captured.size + 1}" + captured.add(arg(0) to (arg>(1))) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + viewModel.sendMessage("/fork my-retry-branch") + advanceUntilIdle() + + val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } + assertNotNull("session.branch_whole should be sent", branchWholeSent) + assertEquals("my-retry-branch", branchWholeSent!!.second["name"]) + assertEquals(sessionId, branchWholeSent.second["session_id"]) + + // Backend answers -32601 unknown method + mockEventsFlow.emit( + WsEvent.RpcError( + "req-1", + JsonRpcError(code = -32601, message = "unknown method: session.branch_whole"), + ), + ) + advanceUntilIdle() + + // Retries ONCE with session.branch using same params + val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH } + assertNotNull("session.branch fallback should be sent", branchSent) + assertEquals("my-retry-branch", branchSent!!.second["name"]) + assertEquals(sessionId, branchSent.second["session_id"]) + assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH }) + + // session.branch result is then handled + mockEventsFlow.emit( + WsEvent.RpcResult( + "req-2", + mapOf( + "session_id" to "runtime-branch-fallback", + "stored_session_id" to "stored-branch-fallback", + "title" to "my-retry-branch", + ), + ), + ) + advanceUntilIdle() + + assertEquals("runtime-branch-fallback", ActiveSessionHolder.activeSessionId.value) + assertEquals("stored-branch-fallback", viewModel.uiState.value.currentSessionId) + assertTrue( + "System message 'Session branched' should be added", + viewModel.uiState.value.messages + .any { it.content.contains("Session branched") }, + ) + } + + @Test + fun testSlashCommand_fork_branchWholeOtherError_noFallback() = + runTest { + val (viewModel, _) = createViewModelWithSession() + val captured = mutableListOf>>() + every { HermesWsClient.send(any(), any(), any()) } answers { + val id = "req-${captured.size + 1}" + captured.add(arg(0) to (arg>(1))) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + viewModel.sendMessage("/fork") + advanceUntilIdle() + + val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } + assertNotNull("session.branch_whole should be sent", branchWholeSent) + + // Backend answers with code other than -32601 + mockEventsFlow.emit( + WsEvent.RpcError( + "req-1", + JsonRpcError(code = -32600, message = "invalid request"), + ), + ) + advanceUntilIdle() + + // No retry/fallback sent + assertEquals(0, captured.count { it.first == WsMethods.SESSION_BRANCH }) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("session.branch_whole") == true, + ) + } + @Test fun testSlashCommand_stop_sendsInterrupt() = runTest { @@ -5234,7 +5369,7 @@ class ChatViewModelTest { viewModel.sendMessage("/fork") runCurrent() - val branchRequest = captured.last { it.first == WsMethods.SESSION_BRANCH }.second + val branchRequest = captured.last { it.first == WsMethods.SESSION_BRANCH_WHOLE }.second mockEventsFlow.emit( WsEvent.RpcResult( branchRequest, @@ -6068,11 +6203,11 @@ class ChatViewModelTest { .SessionMessagesResponse(messages = emptyList()), ) - // /fork sends session.branch keyed on the runtime id. + // /fork sends session.branch_whole keyed on the runtime id. viewModel.sendMessage("/fork") advanceUntilIdle() - val branchId = captured.last { it.first == WsMethods.SESSION_BRANCH }.second + val branchId = captured.last { it.first == WsMethods.SESSION_BRANCH_WHOLE }.second val branchStorage = "branch-storage-1" mockEventsFlow.emit( WsEvent.RpcResult( From 2e0fd46a4c1d9739cc594e03f4a6bffeabc55d42 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:35:25 +0300 Subject: [PATCH 029/107] feat(#1292): plugin settings/diagnostics and persisted edit previews (#1304) --- .../data/ws/PluginManageModels.kt | 102 +++++ .../data/ws/PluginManageRepository.kt | 52 +++ .../ui/chat/ChatServerMessageMapper.kt | 16 +- .../hermescontrol/ui/chat/ToolResultParser.kt | 17 +- .../ui/chat/tool/render/FileRenderers.kt | 12 + .../m57/hermescontrol/ui/common/DetailRows.kt | 2 +- .../hermescontrol/ui/plugins/PluginsScreen.kt | 26 +- .../ui/plugins/PluginsViewModel.kt | 213 ++++++++++ .../plugins/components/PluginDetailDialog.kt | 397 ++++++++++++++++++ app/src/main/res/values/strings.xml | 20 + .../data/ws/PluginManageModelsTest.kt | 376 +++++++++++++++++ .../data/ws/PluginManageRepositoryTest.kt | 236 +++++++++++ .../chat/PersistedEditPreviewHydrationTest.kt | 327 +++++++++++++++ 13 files changed, 1786 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/plugins/components/PluginDetailDialog.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageModelsTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/PersistedEditPreviewHydrationTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageModels.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageModels.kt new file mode 100644 index 00000000..a41b4c60 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageModels.kt @@ -0,0 +1,102 @@ +package com.m57.hermescontrol.data.ws + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +enum class PluginSettingFieldType { + @SerialName("string") + STRING, + + @SerialName("number") + NUMBER, + + @SerialName("boolean") + BOOLEAN, + + @SerialName("enum") + ENUM, + + @SerialName("secret") + SECRET, + + @SerialName("json") + JSON, +} + +@Serializable +data class PluginSettingField( + val key: String, + val type: PluginSettingFieldType, + val label: String, + val description: String = "", + val required: Boolean = false, + val value: JsonElement? = null, + val default: JsonElement? = null, + val choices: List? = null, + val env: String? = null, + @SerialName("has_value") val hasValue: Boolean? = null, +) + +@Serializable +enum class PluginServerState { + @SerialName("connected") + CONNECTED, + + @SerialName("app_not_running") + APP_NOT_RUNNING, + + @SerialName("endpoint_unavailable") + ENDPOINT_UNAVAILABLE, + + @SerialName("no_interactive_session") + NO_INTERACTIVE_SESSION, + + @SerialName("version_too_old") + VERSION_TOO_OLD, + + @SerialName("missing_app") + MISSING_APP, + + @SerialName("unknown") + UNKNOWN, +} + +@Serializable +data class PluginServerRow( + val name: String, + val state: PluginServerState, + val sentence: String = "", +) + +@Serializable +data class AgentPluginRow( + val name: String, + val key: String? = null, + val version: String = "", + val description: String = "", + val source: String = "", + val status: String = "", + val portable: Boolean = false, + @SerialName("install_dir") val installDir: String = "", + @SerialName("has_desktop_half") val hasDesktopHalf: Boolean = false, + val servers: List = emptyList(), + @SerialName("settings_schema") val settingsSchema: List? = null, + @SerialName("catalog_name") val catalogName: String? = null, + @SerialName("catalog_tier") val catalogTier: String? = null, + @SerialName("installed_sha") val installedSha: String? = null, + @SerialName("catalog_sha") val catalogSha: String? = null, + @SerialName("catalog_version") val catalogVersion: String? = null, + @SerialName("update_available") val updateAvailable: Boolean? = null, + @SerialName("pinned_sha") val pinnedSha: String? = null, +) + +@Serializable +data class PluginsManageSettingsResult( + val ok: Boolean = false, + val name: String? = null, + val written: List? = null, + val plugin: AgentPluginRow? = null, + val error: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt new file mode 100644 index 00000000..460e5bcf --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt @@ -0,0 +1,52 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement + +/** + * Manages plugin settings and inspection through the gateway's canonical `plugins.manage` RPC. + */ +class PluginManageRepository( + private val rpcRequest: suspend (String, Map) -> Any? = { method, params -> + val deferred = HermesWsClient.request(method, params.filterValues { it != null }.mapValues { it.value ?: "" }) + try { + deferred.await() + } catch (e: CancellationException) { + deferred.cancel(e) + throw e + } + }, +) { + suspend fun listPlugins(profile: String? = null): List { + val params = mutableMapOf("action" to "list") + if (!profile.isNullOrBlank()) { + params["profile"] = profile + } + val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params) + val element = result.toJsonElement() + val pluginsElement = (element as? JsonObject)?.get("plugins") ?: return emptyList() + return OkHttpProvider.json.decodeFromJsonElement>(pluginsElement) + } + + suspend fun saveSettings( + key: String, + values: Map, + profile: String? = null, + ): PluginsManageSettingsResult { + val params = + mutableMapOf( + "action" to "settings", + "key" to key, + "values" to JsonObject(values), + ) + if (!profile.isNullOrBlank()) { + params["profile"] = profile + } + val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params) + return OkHttpProvider.json.decodeFromJsonElement(result.toJsonElement()) + } +} 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 a451dcc0..1e67bfc2 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 @@ -6,6 +6,8 @@ import com.m57.hermescontrol.data.model.AttachmentSource import com.m57.hermescontrol.data.model.SessionMessage import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.notification.ReplyNotificationTracker +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import kotlinx.serialization.json.JsonObject /** * Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s. @@ -179,7 +181,19 @@ internal fun mapServerMessages( ?: existingById[restId]?.timestamp ?: System.currentTimeMillis() - val rawContent = msg.visibleText + val rawContent = + if (role == MessageRole.TOOL && msg.display_metadata != null) { + // Enrich tool content with display_metadata so persisted edit previews + // (inline_diff) survive reload into the diff renderer + val parsedContent = ToolJson.parseMaybeObject(msg.content) + if (parsedContent != null && !parsedContent.containsKey("display_metadata")) { + JsonObject(parsedContent + ("display_metadata" to msg.display_metadata)).toString() + } else { + msg.visibleText + } + } else { + msg.visibleText + } // #1284: projected reasoning already excludes public commentary; it is authoritative. val rowReasoning = if (msg.hasDisplayReasoning) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt index 7e52589a..0d12fb09 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt @@ -52,7 +52,22 @@ fun parseToolOutput( } else { rawArgs } - val result = element["result"] as? JsonObject ?: element + val rawResultObj = element["result"] as? JsonObject + val displayMetadata = element["display_metadata"] + val result = + when { + rawResultObj != null && displayMetadata != null && !rawResultObj.containsKey("display_metadata") -> { + JsonObject(rawResultObj + ("display_metadata" to displayMetadata)) + } + + rawResultObj != null -> { + rawResultObj + } + + else -> { + element + } + } ToolViewBuilder.build( toolName = resolvedToolName ?: "tool", diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt index ae8095df..8a534ee0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt @@ -58,6 +58,18 @@ internal object FileEditSupport { } } + // Support persisted edit previews from display_metadata.tool_result_metadata.inline_diff or display_metadata.inline_diff + val displayMetadata = + record["display_metadata"] as? JsonObject + ?: ToolJson.parseMaybeObject(record["display_metadata"]) + val toolResultMeta = + displayMetadata?.get("tool_result_metadata") as? JsonObject + ?: ToolJson.parseMaybeObject(displayMetadata?.get("tool_result_metadata")) + val metaDiff = toolResultMeta?.get("inline_diff") ?: displayMetadata?.get("inline_diff") + if (metaDiff is JsonPrimitive && metaDiff.isString && metaDiff.content.trim().isNotEmpty()) { + return stripInlineDiffChrome(metaDiff.content) + } + return "" } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt index c5763b3b..bd9195ad 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt @@ -131,7 +131,7 @@ fun WebhookSubscription.toDetailRows(): List = * Map a free-form status string to a semantic tone. Recognises the app's * common status vocabulary (enabled/active/running/ok → success, etc.). */ -private fun toneForStatus(status: String?): DetailRowTone { +fun toneForStatus(status: String?): DetailRowTone { if (status.isNullOrBlank()) return DetailRowTone.NONE return when (status.lowercase()) { "enabled", "active", "running", "ok", "online", "connected" -> DetailRowTone.SUCCESS diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index d691fc92..757ed094 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -69,6 +69,7 @@ import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.toDetailRows import com.m57.hermescontrol.ui.plugins.components.CatalogPluginsView +import com.m57.hermescontrol.ui.plugins.components.PluginDetailDialog @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -273,7 +274,7 @@ fun PluginsScreen( plugin = plugin, state = state, viewModel = viewModel, - onClick = { showDetail = plugin }, + onClick = { viewModel.openPluginDetail(plugin) }, ) } @@ -289,7 +290,10 @@ fun PluginsScreen( ) } items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin -> - OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin }) + OrphanPluginCard( + plugin = plugin, + onClick = { viewModel.openPluginDetail(plugin) }, + ) } } } @@ -313,11 +317,19 @@ fun PluginsScreen( } } - showDetail?.let { plugin -> - DetailDialog( - title = plugin.name, - rows = plugin.toDetailRows(), - onDismiss = { showDetail = null }, + state.activeDetailPlugin?.let { plugin -> + PluginDetailDialog( + plugin = plugin, + agentPluginRow = state.pluginDetailRow, + isLoading = state.isPluginDetailLoading, + isSaving = state.isPluginDetailSaving, + edits = state.pluginDetailEdits, + onFieldChange = { key, value -> viewModel.setPluginDetailEdit(key, value) }, + onSaveSettings = { viewModel.savePluginSettings() }, + onDismiss = { + showDetail = null + viewModel.closePluginDetail() + }, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index a05daba1..137dcc7d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.local.SwrCache import com.m57.hermescontrol.data.model.AgentPluginInstallBody +import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.model.PluginCatalogEntry import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginProvidersPutRequest @@ -16,7 +17,11 @@ import com.m57.hermescontrol.data.model.ProviderOption import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.data.ws.AgentPluginRow +import com.m57.hermescontrol.data.ws.PluginManageRepository import com.m57.hermescontrol.data.ws.PluginRemovalRepository +import com.m57.hermescontrol.data.ws.PluginSettingField +import com.m57.hermescontrol.data.ws.PluginSettingFieldType import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.safeLaunchAction import com.m57.hermescontrol.ui.common.safeLaunchLoad @@ -29,6 +34,12 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull enum class PluginsTab { INSTALLED, @@ -64,6 +75,12 @@ data class PluginsUiState( // Confirm remove dialog val removeConfirmPlugin: String? = null, val updateConsent: PluginUpdateConsent? = null, + // Active plugin detail with settings & diagnostics + val activeDetailPlugin: PluginInfo? = null, + val isPluginDetailLoading: Boolean = false, + val pluginDetailRow: AgentPluginRow? = null, + val pluginDetailEdits: Map = emptyMap(), + val isPluginDetailSaving: Boolean = false, // Rescan val rescanBusy: Boolean = false, // Catalog state @@ -85,6 +102,7 @@ data class PluginsUiState( class PluginsViewModel( private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(), + private val pluginManage: PluginManageRepository = PluginManageRepository(), ) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(PluginsUiState()) @@ -109,6 +127,9 @@ class PluginsViewModel( rowBusy = null, removeConfirmPlugin = null, updateConsent = null, + activeDetailPlugin = null, + pluginDetailRow = null, + pluginDetailEdits = emptyMap(), rescanBusy = false, ) } @@ -684,6 +705,198 @@ class PluginsViewModel( ) } + fun openPluginDetail(plugin: PluginInfo) { + _uiState.update { + it.copy( + activeDetailPlugin = plugin, + isPluginDetailLoading = true, + pluginDetailRow = null, + pluginDetailEdits = emptyMap(), + ) + } + viewModelScope.launch { + try { + val profile = AuthManager.activeProfileId.value + val rows = pluginManage.listPlugins(profile) + val matchingRow = rows.firstOrNull { it.key == plugin.name || it.name == plugin.name } + val initialEdits = mutableMapOf() + matchingRow?.settingsSchema?.forEach { field -> + if (field.type != PluginSettingFieldType.SECRET) { + val strVal = + when (val v = field.value ?: field.default) { + null -> "" + is JsonPrimitive -> v.content + else -> v.toString() + } + initialEdits[field.key] = strVal + } + } + _uiState.update { + it.copy( + isPluginDetailLoading = false, + pluginDetailRow = matchingRow, + pluginDetailEdits = initialEdits, + ) + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + _uiState.update { + it.copy( + isPluginDetailLoading = false, + pluginDetailRow = null, + ) + } + } + } + } + + fun closePluginDetail() { + _uiState.update { + it.copy( + activeDetailPlugin = null, + pluginDetailRow = null, + pluginDetailEdits = emptyMap(), + isPluginDetailSaving = false, + ) + } + } + + fun setPluginDetailEdit( + key: String, + value: String, + ) { + _uiState.update { + it.copy(pluginDetailEdits = it.pluginDetailEdits + (key to value)) + } + } + + fun savePluginSettings() { + val plugin = _uiState.value.pluginDetailRow ?: return + val schema = plugin.settingsSchema ?: return + val canonicalKey = plugin.key ?: plugin.name + val currentEdits = _uiState.value.pluginDetailEdits + + _uiState.update { it.copy(isPluginDetailSaving = true) } + viewModelScope.launch { + try { + val profile = AuthManager.activeProfileId.value + val valuesToSave = mutableMapOf() + + for (field in schema) { + val raw = currentEdits[field.key] ?: "" + when (field.type) { + PluginSettingFieldType.SECRET -> { + val envVar = field.env + if (raw.isNotBlank() && !envVar.isNullOrBlank()) { + val envResult = + safeApiCall { + ApiClient.hermesApi.updateEnvVar(EnvVarUpdate(key = envVar, value = raw)) + } + if (envResult is NetworkResult.Failure) { + throw IllegalStateException( + "Failed to update secret $envVar: ${envResult.error.message}", + ) + } + } + } + + PluginSettingFieldType.STRING -> { + valuesToSave[field.key] = JsonPrimitive(raw) + } + + PluginSettingFieldType.NUMBER -> { + val num = + raw.trim().toLongOrNull() + ?: raw.trim().toDoubleOrNull() + if (num != null) { + valuesToSave[field.key] = + if (num is Long) JsonPrimitive(num) else JsonPrimitive(num as Double) + } else if (raw.trim().isNotEmpty()) { + throw IllegalArgumentException("${field.label}: expected a number") + } + } + + PluginSettingFieldType.BOOLEAN -> { + valuesToSave[field.key] = JsonPrimitive(raw.trim().equals("true", ignoreCase = true)) + } + + PluginSettingFieldType.ENUM -> { + if (!field.choices.isNullOrEmpty() && raw.trim() !in field.choices) { + throw IllegalArgumentException( + "${field.label}: must be one of ${field.choices.joinToString()}", + ) + } + valuesToSave[field.key] = JsonPrimitive(raw.trim()) + } + + PluginSettingFieldType.JSON -> { + val parsed = + if (raw.trim().isEmpty()) { + JsonPrimitive("") + } else { + Json.parseToJsonElement(raw.trim()) + } + valuesToSave[field.key] = parsed + } + } + } + + if (valuesToSave.isNotEmpty()) { + val result = pluginManage.saveSettings(canonicalKey, valuesToSave, profile) + if (!result.ok) { + throw IllegalStateException(result.error ?: "Failed to save settings") + } + if (result.plugin != null) { + val refreshed = result.plugin + val newEdits = mutableMapOf() + refreshed.settingsSchema?.forEach { f -> + if (f.type != PluginSettingFieldType.SECRET) { + val strVal = + when (val v = f.value ?: f.default) { + null -> "" + is JsonPrimitive -> v.content + else -> v.toString() + } + newEdits[f.key] = strVal + } + } + _uiState.update { + it.copy( + isPluginDetailSaving = false, + pluginDetailRow = refreshed, + pluginDetailEdits = newEdits, + toastMessage = "Plugin settings saved", + ) + } + return@launch + } + } + + // If only secrets were saved or result plugin is null + _uiState.update { + it.copy( + isPluginDetailSaving = false, + pluginDetailEdits = + it.pluginDetailEdits.filterKeys { k -> + schema.none { f -> f.key == k && f.type == PluginSettingFieldType.SECRET } + }, + toastMessage = "Plugin settings saved", + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _uiState.update { + it.copy( + isPluginDetailSaving = false, + toastMessage = "Failed to save settings: ${e.message}", + ) + } + } + } + } + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/PluginDetailDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/PluginDetailDialog.kt new file mode 100644 index 00000000..68998a31 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/PluginDetailDialog.kt @@ -0,0 +1,397 @@ +package com.m57.hermescontrol.ui.plugins.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.PluginInfo +import com.m57.hermescontrol.data.ws.AgentPluginRow +import com.m57.hermescontrol.data.ws.PluginServerRow +import com.m57.hermescontrol.data.ws.PluginServerState +import com.m57.hermescontrol.data.ws.PluginSettingField +import com.m57.hermescontrol.data.ws.PluginSettingFieldType +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.common.DetailRow +import com.m57.hermescontrol.ui.common.DetailRowTone +import com.m57.hermescontrol.ui.common.ExposedDropdownField +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.common.toDetailRows +import com.m57.hermescontrol.ui.common.toneForStatus + +@Composable +fun PluginDetailDialog( + plugin: PluginInfo, + agentPluginRow: AgentPluginRow?, + isLoading: Boolean, + isSaving: Boolean, + edits: Map, + onFieldChange: (String, String) -> Unit, + onSaveSettings: () -> Unit, + onDismiss: () -> Unit, +) { + val statusColors = LocalHermesStatusColors.current + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + HermesScaffold( + title = { Text(plugin.name, style = MaterialTheme.typography.titleLarge) }, + navigationIcon = NavIcon.Back(onDismiss), + isRefreshing = false, + ) { _ -> + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Standard metadata rows + val rows = plugin.toDetailRows().filter { !it.value.isNullOrBlank() } + rows.forEachIndexed { index, (label, value, tone) -> + if (index > 0) { + HorizontalDivider() + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value ?: "", + style = MaterialTheme.typography.bodyMedium, + color = + when (tone) { + DetailRowTone.SUCCESS -> statusColors.success + DetailRowTone.WARNING -> statusColors.warning + DetailRowTone.ERROR -> statusColors.error + DetailRowTone.INFO -> statusColors.info + DetailRowTone.NEUTRAL -> statusColors.neutral + DetailRowTone.NONE -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + if (isLoading) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.plugins_settings_loading), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Diagnostics section (only if servers present) + val servers = agentPluginRow?.servers.orEmpty() + if (servers.isNotEmpty()) { + HorizontalDivider() + Text( + text = stringResource(R.string.plugins_diagnostics_heading), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + servers.forEach { server -> + PluginServerDiagnosticRow(server = server) + } + } + + // Settings section (only if schema present) + val schema = agentPluginRow?.settingsSchema.orEmpty() + if (schema.isNotEmpty()) { + HorizontalDivider() + Text( + text = stringResource(R.string.plugins_settings_heading), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + schema.forEach { field -> + PluginSettingFieldRow( + field = field, + value = edits[field.key] ?: "", + enabled = !isSaving, + onChange = { onFieldChange(field.key, it) }, + ) + } + + Button( + onClick = onSaveSettings, + enabled = !isSaving, + modifier = Modifier.align(Alignment.End), + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.plugins_settings_save)) + } + } + + // Close action + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_close)) + } + } + } + } + } + } +} + +@Composable +private fun PluginServerDiagnosticRow(server: PluginServerRow) { + val statusColors = LocalHermesStatusColors.current + val (statusLabel, statusColor, badgeType) = + when (server.state) { + PluginServerState.CONNECTED -> { + Triple( + stringResource(R.string.plugins_server_state_connected), + statusColors.success, + StatusBadgeType.SUCCESS, + ) + } + + PluginServerState.APP_NOT_RUNNING -> { + Triple( + stringResource(R.string.plugins_server_state_app_not_running), + statusColors.warning, + StatusBadgeType.WARNING, + ) + } + + PluginServerState.ENDPOINT_UNAVAILABLE -> { + Triple( + stringResource(R.string.plugins_server_state_endpoint_unavailable), + statusColors.warning, + StatusBadgeType.WARNING, + ) + } + + PluginServerState.NO_INTERACTIVE_SESSION -> { + Triple( + stringResource(R.string.plugins_server_state_no_interactive_session), + statusColors.warning, + StatusBadgeType.WARNING, + ) + } + + PluginServerState.VERSION_TOO_OLD -> { + Triple( + stringResource(R.string.plugins_server_state_version_too_old), + statusColors.error, + StatusBadgeType.ERROR, + ) + } + + PluginServerState.MISSING_APP -> { + Triple( + stringResource(R.string.plugins_server_state_missing_app), + statusColors.error, + StatusBadgeType.ERROR, + ) + } + + PluginServerState.UNKNOWN -> { + Triple( + stringResource(R.string.plugins_server_state_unknown), + statusColors.warning, + StatusBadgeType.WARNING, + ) + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = server.name, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + StatusBadge(text = statusLabel, status = badgeType) + } + if (server.sentence.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = server.sentence, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun PluginSettingFieldRow( + field: PluginSettingField, + value: String, + enabled: Boolean, + onChange: (String) -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = field.label.ifEmpty { field.key }, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + if (field.type == PluginSettingFieldType.SECRET) { + val isSet = field.hasValue == true + val badgeRes = + if (isSet) { + R.string.plugins_settings_secret_set + } else { + R.string.plugins_settings_secret_not_set + } + StatusBadge( + text = stringResource(badgeRes), + status = if (isSet) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING, + ) + } + } + if (field.description.isNotBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = field.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (field.type == PluginSettingFieldType.SECRET && !field.env.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(R.string.plugins_settings_secret_hint, field.env), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + + when (field.type) { + PluginSettingFieldType.ENUM -> { + ExposedDropdownField( + label = field.label.ifEmpty { field.key }, + options = field.choices.orEmpty().ifEmpty { listOf(value) }, + selectedValue = value, + onOptionSelected = onChange, + ) + } + + PluginSettingFieldType.BOOLEAN -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + checked = value.equals("true", ignoreCase = true), + onCheckedChange = { onChange(it.toString()) }, + enabled = enabled, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = + if (value.equals("true", ignoreCase = true)) { + stringResource(R.string.memory_provider_bool_on) + } else { + stringResource(R.string.memory_provider_bool_off) + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + else -> { + OutlinedTextField( + value = value, + onValueChange = onChange, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + label = { + Text( + if (field.type == PluginSettingFieldType.SECRET) { + stringResource(R.string.memory_provider_secret_label) + } else { + field.label.ifEmpty { field.key } + }, + ) + }, + singleLine = field.type != PluginSettingFieldType.JSON, + visualTransformation = + if (field.type == PluginSettingFieldType.SECRET) { + PasswordVisualTransformation() + } else { + androidx.compose.ui.text.input.VisualTransformation.None + }, + keyboardOptions = + if (field.type == PluginSettingFieldType.NUMBER) { + KeyboardOptions(keyboardType = KeyboardType.Decimal) + } else { + KeyboardOptions.Default + }, + ) + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d150e5ed..ae26e415 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1063,6 +1063,7 @@ No description provided. Update Uninstall + Settings Enable Install Remove @@ -1078,6 +1079,25 @@ Remove Plugin This will remove the "%1$s" plugin from your agent. Auth Required + Loading plugin settings… + Plugin Settings + Save Settings + Plugin settings saved + Failed to save settings: %1$s + Diagnostics + No diagnostics reported + Settings + set + not set + Stored in .env as %1$s (leave blank to keep) + No configurable settings + Connected + App not running + Endpoint unavailable + No interactive session + Version too old + App missing + Status unknown Auth command: %1$s Dashboard slots: %1$s Orphaned Dashboard Plugins diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageModelsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageModelsTest.kt new file mode 100644 index 00000000..0fb5cc06 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageModelsTest.kt @@ -0,0 +1,376 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +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.Assert.fail +import org.junit.Test + +class PluginManageModelsTest { + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes and encodes string type correctly`() { + val jsonString = + """ + { + "key": "api_host", + "type": "string", + "label": "API Host", + "description": "Hostname for API", + "required": true, + "value": "https://api.example.com", + "default": "https://localhost", + "choices": null, + "env": "API_HOST", + "has_value": true + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("api_host", field.key) + assertEquals(PluginSettingFieldType.STRING, field.type) + assertEquals("API Host", field.label) + assertEquals("Hostname for API", field.description) + assertTrue(field.required) + assertEquals(JsonPrimitive("https://api.example.com"), field.value) + assertEquals(JsonPrimitive("https://localhost"), field.default) + assertNull(field.choices) + assertEquals("API_HOST", field.env) + assertEquals(true, field.hasValue) + + val encoded = OkHttpProvider.json.encodeToString(field) + val roundTripped = OkHttpProvider.json.decodeFromString(encoded) + assertEquals(field, roundTripped) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes and encodes number type with numeric value and default`() { + val jsonString = + """ + { + "key": "port", + "type": "number", + "label": "Port Number", + "value": 8080, + "default": 3000 + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("port", field.key) + assertEquals(PluginSettingFieldType.NUMBER, field.type) + assertEquals(JsonPrimitive(8080), field.value) + assertEquals(JsonPrimitive(3000), field.default) + assertEquals("", field.description) + assertFalse(field.required) + assertNull(field.choices) + assertNull(field.env) + assertNull(field.hasValue) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes and encodes boolean type with default false`() { + val jsonString = + """ + { + "key": "enabled", + "type": "boolean", + "label": "Enabled", + "value": true, + "default": false + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("enabled", field.key) + assertEquals(PluginSettingFieldType.BOOLEAN, field.type) + assertEquals(JsonPrimitive(true), field.value) + assertEquals(JsonPrimitive(false), field.default) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes enum type with choices`() { + val jsonString = + """ + { + "key": "mode", + "type": "enum", + "label": "Execution Mode", + "choices": ["fast", "balanced", "thorough"], + "value": "fast", + "default": "balanced" + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("mode", field.key) + assertEquals(PluginSettingFieldType.ENUM, field.type) + assertEquals(listOf("fast", "balanced", "thorough"), field.choices) + assertEquals(JsonPrimitive("fast"), field.value) + assertEquals(JsonPrimitive("balanced"), field.default) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes secret type with masked or null value and has_value flag`() { + val jsonString = + """ + { + "key": "api_token", + "type": "secret", + "label": "API Token", + "value": null, + "env": "SECRET_TOKEN", + "has_value": true + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("api_token", field.key) + assertEquals(PluginSettingFieldType.SECRET, field.type) + assertEquals(null, field.value) + assertEquals("SECRET_TOKEN", field.env) + assertEquals(true, field.hasValue) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes json type with object or array values`() { + val jsonString = + """ + { + "key": "extra_config", + "type": "json", + "label": "Extra Config", + "value": {"timeout": 30, "retries": 3}, + "default": {} + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("extra_config", field.key) + assertEquals(PluginSettingFieldType.JSON, field.type) + assertTrue(field.value is JsonObject) + val valueObj = field.value as JsonObject + assertEquals(JsonPrimitive(30), valueObj["timeout"]) + assertEquals(JsonPrimitive(3), valueObj["retries"]) + assertTrue(field.default is JsonObject) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types (error case). + @Test + fun `PluginSettingField decoding fails on unknown type value`() { + val jsonString = + """ + { + "key": "invalid_field", + "type": "unknown_future_type", + "label": "Invalid" + } + """.trimIndent() + + try { + OkHttpProvider.json.decodeFromString(jsonString) + fail("Expected SerializationException for unknown type") + } catch (_: SerializationException) { + // expected + } + } + + // CONTRACT: PluginServerState (CONNECTED, APP_NOT_RUNNING, ENDPOINT_UNAVAILABLE, VERSION_TOO_OLD, MISSING_APP, UNKNOWN). + @Test + fun `PluginServerState decodes all enum variants from snake_case strings`() { + val expectedStates = + mapOf( + "connected" to PluginServerState.CONNECTED, + "app_not_running" to PluginServerState.APP_NOT_RUNNING, + "endpoint_unavailable" to PluginServerState.ENDPOINT_UNAVAILABLE, + "no_interactive_session" to PluginServerState.NO_INTERACTIVE_SESSION, + "version_too_old" to PluginServerState.VERSION_TOO_OLD, + "missing_app" to PluginServerState.MISSING_APP, + "unknown" to PluginServerState.UNKNOWN, + ) + + for ((serialized, expectedEnum) in expectedStates) { + val decoded = OkHttpProvider.json.decodeFromString("\"$serialized\"") + assertEquals("Mismatch for $serialized", expectedEnum, decoded) + val reEncoded = OkHttpProvider.json.encodeToString(expectedEnum) + assertEquals("\"$serialized\"", reEncoded) + } + } + + // CONTRACT: PluginServerState (CONNECTED, APP_NOT_RUNNING, ENDPOINT_UNAVAILABLE, VERSION_TOO_OLD, MISSING_APP, UNKNOWN) via PluginServerRow. + @Test + fun `PluginServerRow decodes name state and sentence correctly`() { + val jsonString = + """ + { + "name": "camofox_service", + "state": "app_not_running", + "sentence": "Camofox browser is not running on port 9333." + } + """.trimIndent() + + val row = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("camofox_service", row.name) + assertEquals(PluginServerState.APP_NOT_RUNNING, row.state) + assertEquals("Camofox browser is not running on port 9333.", row.sentence) + } + + // CONTRACT: AgentPluginRow decoding from backend plugins.manage list payload shape. + @Test + fun `AgentPluginRow decodes full payload shape with servers and settings schema`() { + val jsonString = + """ + { + "name": "camofox", + "key": "camofox-browser", + "version": "1.2.0", + "description": "Anti-detect browser automation", + "source": "installed", + "status": "active", + "portable": true, + "install_dir": "/home/user/.hermes/plugins/camofox", + "has_desktop_half": true, + "servers": [ + { + "name": "cdp", + "state": "connected", + "sentence": "CDP connected" + } + ], + "settings_schema": [ + { + "key": "port", + "type": "number", + "label": "CDP Port", + "value": 9222 + } + ], + "catalog_name": "camofox", + "catalog_tier": "community", + "installed_sha": "abc1234", + "catalog_sha": "def5678", + "catalog_version": "1.3.0", + "update_available": true, + "pinned_sha": "abc1234" + } + """.trimIndent() + + val row = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("camofox", row.name) + assertEquals("camofox-browser", row.key) + assertEquals("1.2.0", row.version) + assertEquals("Anti-detect browser automation", row.description) + assertEquals("installed", row.source) + assertEquals("status" to "active", "status" to row.status) + assertTrue(row.portable) + assertEquals("/home/user/.hermes/plugins/camofox", row.installDir) + assertTrue(row.hasDesktopHalf) + assertEquals(1, row.servers.size) + assertEquals("cdp", row.servers[0].name) + assertEquals(PluginServerState.CONNECTED, row.servers[0].state) + assertEquals(1, row.settingsSchema?.size) + assertEquals("port", row.settingsSchema?.get(0)?.key) + assertEquals(PluginSettingFieldType.NUMBER, row.settingsSchema?.get(0)?.type) + assertEquals("camofox", row.catalogName) + assertEquals("community", row.catalogTier) + assertEquals("abc1234", row.installedSha) + assertEquals("def5678", row.catalogSha) + assertEquals("1.3.0", row.catalogVersion) + assertEquals(true, row.updateAvailable) + assertEquals("abc1234", row.pinnedSha) + } + + // CONTRACT: AgentPluginRow decoding from backend plugins.manage list payload shape (defaults/nulls). + @Test + fun `AgentPluginRow decodes minimal required payload using defaults`() { + val jsonString = + """ + { + "name": "minimal_plugin" + } + """.trimIndent() + + val row = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("minimal_plugin", row.name) + assertNull(row.key) + assertEquals("", row.version) + assertEquals("", row.description) + assertEquals("", row.source) + assertEquals("", row.status) + assertFalse(row.portable) + assertEquals("", row.installDir) + assertFalse(row.hasDesktopHalf) + assertTrue(row.servers.isEmpty()) + assertNull(row.settingsSchema) + assertNull(row.catalogName) + assertNull(row.catalogTier) + assertNull(row.installedSha) + assertNull(row.catalogSha) + assertNull(row.catalogVersion) + assertNull(row.updateAvailable) + assertNull(row.pinnedSha) + } + + // CONTRACT: PluginsManageSettingsResult decoding happy path and error cases. + @Test + fun `PluginsManageSettingsResult decodes success with updated plugin and written keys`() { + val jsonString = + """ + { + "ok": true, + "name": "camofox", + "written": ["port", "headless"], + "plugin": { + "name": "camofox", + "version": "1.2.0" + }, + "error": null + } + """.trimIndent() + + val result = OkHttpProvider.json.decodeFromString(jsonString) + assertTrue(result.ok) + assertEquals("camofox", result.name) + assertEquals(listOf("port", "headless"), result.written) + assertNotNull(result.plugin) + assertEquals("camofox", result.plugin?.name) + assertNull(result.error) + } + + // CONTRACT: PluginsManageSettingsResult decoding happy path and error cases. + @Test + fun `PluginsManageSettingsResult decodes failure result with error message`() { + val jsonString = + """ + { + "ok": false, + "name": "camofox", + "error": "Validation failed: port must be an integer" + } + """.trimIndent() + + val result = OkHttpProvider.json.decodeFromString(jsonString) + assertFalse(result.ok) + assertEquals("camofox", result.name) + assertEquals("Validation failed: port must be an integer", result.error) + assertNull(result.written) + assertNull(result.plugin) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt new file mode 100644 index 00000000..e861aa1a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt @@ -0,0 +1,236 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.encodeToJsonElement +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.Assert.fail +import org.junit.Test + +class PluginManageRepositoryTest { + // CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list. + @Test + fun `listPlugins without profile calls PLUGINS_MANAGE action list and decodes plugins list`() = + runTest { + var capturedMethod = "" + var capturedParams: Map = emptyMap() + + val mockPayload = + OkHttpProvider.json.parseToJsonElement( + """ + { + "plugins": [ + { + "name": "plugin-alpha", + "version": "1.0.0", + "status": "active" + }, + { + "name": "plugin-beta", + "version": "2.1.0", + "status": "disabled" + } + ] + } + """.trimIndent(), + ) + + val repository = + PluginManageRepository { method, params -> + capturedMethod = method + capturedParams = params + mockPayload + } + + val plugins = repository.listPlugins() + + assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) + assertEquals(mapOf("action" to "list"), capturedParams) + assertEquals(2, plugins.size) + assertEquals("plugin-alpha", plugins[0].name) + assertEquals("1.0.0", plugins[0].version) + assertEquals("active", plugins[0].status) + assertEquals("plugin-beta", plugins[1].name) + assertEquals("2.1.0", plugins[1].version) + assertEquals("disabled", plugins[1].status) + } + + // CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list. + @Test + fun `listPlugins with non-blank profile includes profile parameter in request`() = + runTest { + var capturedMethod = "" + var capturedParams: Map = emptyMap() + + val mockPayload = + OkHttpProvider.json.parseToJsonElement( + """ + { + "plugins": [] + } + """.trimIndent(), + ) + + val repository = + PluginManageRepository { method, params -> + capturedMethod = method + capturedParams = params + mockPayload + } + + val plugins = repository.listPlugins(profile = "work") + + assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) + assertEquals(mapOf("action" to "list", "profile" to "work"), capturedParams) + assertTrue(plugins.isEmpty()) + } + + // CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list. + @Test + fun `listPlugins returns empty list when plugins key is missing or response is not a JsonObject`() = + runTest { + val emptyObjRepo = + PluginManageRepository { _, _ -> + OkHttpProvider.json.parseToJsonElement("{}") + } + assertTrue(emptyObjRepo.listPlugins().isEmpty()) + + val nonObjRepo = + PluginManageRepository { _, _ -> + OkHttpProvider.json.parseToJsonElement("[]") + } + assertTrue(nonObjRepo.listPlugins().isEmpty()) + + val nullRepo = + PluginManageRepository { _, _ -> + null + } + assertTrue(nullRepo.listPlugins().isEmpty()) + } + + // CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result. + @Test + fun `saveSettings without profile calls PLUGINS_MANAGE with action settings key and JsonObject values`() = + runTest { + var capturedMethod = "" + var capturedParams: Map = emptyMap() + + val mockResponse = + OkHttpProvider.json.parseToJsonElement( + """ + { + "ok": true, + "name": "camofox", + "written": ["port", "headless"], + "plugin": { + "name": "camofox", + "version": "1.0.0" + } + } + """.trimIndent(), + ) + + val repository = + PluginManageRepository { method, params -> + capturedMethod = method + capturedParams = params + mockResponse + } + + val valuesToSave: Map = + mapOf( + "port" to JsonPrimitive(9222), + "headless" to JsonPrimitive(true), + ) + + val result = repository.saveSettings(key = "camofox", values = valuesToSave) + + assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) + assertEquals("settings", capturedParams["action"]) + assertEquals("camofox", capturedParams["key"]) + assertEquals(JsonObject(valuesToSave), capturedParams["values"]) + assertFalse(capturedParams.containsKey("profile")) + + assertTrue(result.ok) + assertEquals("camofox", result.name) + assertEquals(listOf("port", "headless"), result.written) + assertNotNull(result.plugin) + assertEquals("camofox", result.plugin?.name) + assertNull(result.error) + } + + // CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result. + @Test + fun `saveSettings with profile includes profile in request and handles error result`() = + runTest { + var capturedMethod = "" + var capturedParams: Map = emptyMap() + + val mockResponse = + OkHttpProvider.json.parseToJsonElement( + """ + { + "ok": false, + "name": "camofox", + "error": "Port already in use" + } + """.trimIndent(), + ) + + val repository = + PluginManageRepository { method, params -> + capturedMethod = method + capturedParams = params + mockResponse + } + + val valuesToSave: Map = + mapOf( + "port" to JsonPrimitive(8080), + ) + + val result = repository.saveSettings(key = "camofox", values = valuesToSave, profile = "staging") + + assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) + assertEquals( + mapOf( + "action" to "settings", + "key" to "camofox", + "values" to JsonObject(valuesToSave), + "profile" to "staging", + ), + capturedParams, + ) + + assertFalse(result.ok) + assertEquals("camofox", result.name) + assertEquals("Port already in use", result.error) + assertNull(result.written) + assertNull(result.plugin) + } + + // CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result (error case). + @Test + fun `saveSettings throws SerializationException when response is malformed`() = + runTest { + val repository = + PluginManageRepository { _, _ -> + OkHttpProvider.json.parseToJsonElement("{\"ok\": \"not-a-boolean\"}") + } + + try { + repository.saveSettings("camofox", emptyMap()) + fail("Expected SerializationException when response JSON is malformed") + } catch (_: SerializationException) { + // Expected + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/PersistedEditPreviewHydrationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/PersistedEditPreviewHydrationTest.kt new file mode 100644 index 00000000..57bf99ef --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/PersistedEditPreviewHydrationTest.kt @@ -0,0 +1,327 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.SessionMessage +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests verifying that persisted edit previews survive transcript loading into [ChatMessage] + * and subsequent tool diff parsing via [parseToolOutput]. + */ +class PersistedEditPreviewHydrationTest { + private val json = Json { ignoreUnknownKeys = true } + + private fun parseJson(raw: String): JsonElement = json.parseToJsonElement(raw) + + private fun toolSessionMessage( + content: String, + displayMetadata: JsonElement? = null, + id: Int = 101, + toolCallId: String = "call-101", + ): SessionMessage = + SessionMessage( + id = id, + role = "tool", + content = JsonPrimitive(content), + tool_call_id = toolCallId, + display_metadata = displayMetadata, + ) + + private fun mapSingle(msg: SessionMessage): ChatMessage = + mapServerMessages( + sessionId = "sess-edit-preview", + messages = listOf(msg), + offset = 0, + latestPaging = true, + liveMessages = emptyList(), + ).single() + + // ── Contract 1: ChatServerMessageMapper enrichment ──────────────────────── + + @Test + fun mapServerMessages_enrichesToolContentWithDisplayMetadataInlineDiff() { + val diffPayload = + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1 +1 @@\n-old\n+new" + } + } + """.trimIndent() + val originalContent = """{"output":"File updated successfully"}""" + val serverMsg = + toolSessionMessage( + content = originalContent, + displayMetadata = parseJson(diffPayload), + ) + + val chatMessage = mapSingle(serverMsg) + + val enriched = parseJson(chatMessage.content) as? JsonObject + assertNotNull("Expected enriched content to parse as JSON object", enriched) + assertEquals( + "File updated successfully", + (enriched?.get("output") as? JsonPrimitive)?.content, + ) + val displayMeta = enriched?.get("display_metadata") as? JsonObject + assertNotNull("Expected display_metadata to be added to content", displayMeta) + val toolResultMeta = displayMeta?.get("tool_result_metadata") as? JsonObject + assertNotNull("Expected tool_result_metadata inside display_metadata", toolResultMeta) + val diff = (toolResultMeta?.get("inline_diff") as? JsonPrimitive)?.content + assertEquals("@@ -1 +1 @@\n-old\n+new", diff) + } + + @Test + fun mapServerMessages_doesNotEnrichWhenDisplayMetadataIsNull() { + val originalContent = """{"output":"no metadata"}""" + val serverMsg = + toolSessionMessage( + content = originalContent, + displayMetadata = null, + ) + + val chatMessage = mapSingle(serverMsg) + + assertEquals(originalContent, chatMessage.content) + } + + @Test + fun mapServerMessages_doesNotEnrichWhenRoleIsNotTool() { + val metadata = + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1 +1 @@\n-old\n+new" + } + } + """.trimIndent() + val originalContent = "Assistant says hello" + val serverMsg = + SessionMessage( + id = 202, + role = "assistant", + content = JsonPrimitive(originalContent), + display_metadata = parseJson(metadata), + ) + + val chatMessage = mapSingle(serverMsg) + + assertEquals(originalContent, chatMessage.content) + } + + @Test + fun mapServerMessages_doesNotOverwriteExistingDisplayMetadataInContent() { + val content = + """ + { + "output": "ok", + "display_metadata": { + "existing": true + } + } + """.trimIndent() + val externalMetadata = + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1 +1 @@\n-old\n+new" + } + } + """.trimIndent() + val serverMsg = + toolSessionMessage( + content = content, + displayMetadata = parseJson(externalMetadata), + ) + + val chatMessage = mapSingle(serverMsg) + + val parsed = parseJson(chatMessage.content) as? JsonObject + assertNotNull(parsed) + val meta = parsed?.get("display_metadata") as? JsonObject + assertEquals("true", (meta?.get("existing") as? JsonPrimitive)?.content) + assertNull(meta?.get("tool_result_metadata")) + } + + @Test + fun mapServerMessages_ignoresNonJsonObjectContent() { + val nonJsonContent = "Plain text output from tool" + val metadata = + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1 +1 @@\n-old\n+new" + } + } + """.trimIndent() + val serverMsg = + toolSessionMessage( + content = nonJsonContent, + displayMetadata = parseJson(metadata), + ) + + val chatMessage = mapSingle(serverMsg) + + assertEquals(nonJsonContent, chatMessage.content) + } + + // ── Contract 2: ToolResultParser / FileRenderers inline diff extraction ── + + @Test + fun parseToolOutput_extractsInlineDiffFromNestedToolResultMetadata() { + val rawDiff = "@@ -1,3 +1,3 @@\n-old line\n+new line" + val content = + """ + { + "name": "patch", + "args": { + "path": "app/src/main/Utils.kt" + }, + "display_metadata": { + "tool_result_metadata": { + "inline_diff": "@@ -1,3 +1,3 @@\n-old line\n+new line" + } + } + } + """.trimIndent() + + val view = parseToolOutput(content = content, toolName = "patch", isRunning = false) + + assertNotNull("Expected parseToolOutput to return a ToolView", view) + assertEquals(rawDiff, view?.inlineDiff) + assertEquals("app/src/main/Utils.kt", view?.diffPath) + assertEquals(1, view?.diffStats?.added) + assertEquals(1, view?.diffStats?.removed) + } + + @Test + fun parseToolOutput_extractsInlineDiffFromFlatDisplayMetadata() { + val rawDiff = "--- a/test.kt\n+++ b/test.kt\n@@ -1 +1 @@\n-alpha\n+beta" + val content = + """ + { + "name": "edit_file", + "args": { + "path": "test.kt" + }, + "display_metadata": { + "inline_diff": "--- a/test.kt\n+++ b/test.kt\n@@ -1 +1 @@\n-alpha\n+beta" + } + } + """.trimIndent() + + val view = parseToolOutput(content = content, toolName = "edit_file", isRunning = false) + + assertNotNull(view) + assertEquals(rawDiff, view?.inlineDiff) + assertEquals("test.kt", view?.diffPath) + assertEquals(1, view?.diffStats?.added) + assertEquals(1, view?.diffStats?.removed) + } + + @Test + fun parseToolOutput_extractsInlineDiffFromResultObjectDirectly() { + val rawDiff = "@@ -1 +1 @@\n-before\n+after" + val content = + """ + { + "name": "patch", + "args": { + "path": "file.txt" + }, + "result": { + "inline_diff": "@@ -1 +1 @@\n-before\n+after" + } + } + """.trimIndent() + + val view = parseToolOutput(content = content, toolName = "patch", isRunning = false) + + assertNotNull(view) + assertEquals(rawDiff, view?.inlineDiff) + assertEquals("file.txt", view?.diffPath) + assertEquals(1, view?.diffStats?.added) + assertEquals(1, view?.diffStats?.removed) + } + + @Test + fun parseToolOutput_absentOrEmptyMetadataYieldsNullInlineDiff() { + val emptyDiffContent = + """ + { + "name": "patch", + "args": { + "path": "file.txt" + }, + "display_metadata": { + "tool_result_metadata": { + "inline_diff": "" + } + } + } + """.trimIndent() + val noDiffView = parseToolOutput(content = emptyDiffContent, toolName = "patch", isRunning = false) + assertNotNull(noDiffView) + assertNull(noDiffView?.inlineDiff) + + val missingDiffContent = + """ + { + "name": "patch", + "args": { + "path": "file.txt" + }, + "result": { + "success": true + } + } + """.trimIndent() + val missingDiffView = parseToolOutput(content = missingDiffContent, toolName = "patch", isRunning = false) + assertNotNull(missingDiffView) + assertNull(missingDiffView?.inlineDiff) + } + + // ── End-to-end integration: Transcript hydration -> ToolView diff parsing ─ + + @Test + fun endToEnd_hydratedTranscriptProducesToolViewWithInlineDiff() { + val serverDiff = "@@ -1,2 +1,2 @@\n-val x = 1\n+val x = 2" + val sessionMsg = + toolSessionMessage( + content = """{"name":"patch","args":{"path":"src/Config.kt"},"result":{"success":true}}""", + displayMetadata = + parseJson( + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1,2 +1,2 @@\n-val x = 1\n+val x = 2" + } + } + """.trimIndent(), + ), + ) + + val chatMessage = mapSingle(sessionMsg) + assertTrue(chatMessage.content.contains("display_metadata")) + + val toolView = + parseToolOutput( + content = chatMessage.content, + toolName = "patch", + isRunning = false, + ) + + assertNotNull("Expected parseToolOutput to succeed for hydrated message content", toolView) + assertEquals(serverDiff, toolView?.inlineDiff) + assertEquals("src/Config.kt", toolView?.diffPath) + assertEquals(1, toolView?.diffStats?.added) + assertEquals(1, toolView?.diffStats?.removed) + } +} From 3c56026a1c3d79d2db295ac16967bb8cec6b1ded Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:43:50 +0300 Subject: [PATCH 030/107] feat(chat): add per-message read-aloud TTS playback (#1305) --- .../com/m57/hermescontrol/data/model/Tts.kt | 26 + .../data/remote/HermesApiService.kt | 8 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 20 + .../ui/chat/components/AndroidSpeech.kt | 288 +++++++ .../ui/chat/components/RememberChatSpeech.kt | 99 +++ .../ui/chat/components/SpeechController.kt | 178 +++++ .../ui/chat/components/SpeechText.kt | 118 +++ .../chat/fullbleed/FullBleedAgentMessage.kt | 29 + .../ui/chat/fullbleed/FullBleedChatList.kt | 13 + app/src/main/res/values/strings.xml | 5 + .../m57/hermescontrol/data/model/TtsTest.kt | 140 ++++ .../ui/chat/components/AndroidSpeechTest.kt | 458 +++++++++++ .../chat/components/SpeechControllerTest.kt | 738 ++++++++++++++++++ .../ui/chat/components/SpeechTextTest.kt | 250 ++++++ 14 files changed, 2370 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/Tts.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeech.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/RememberChatSpeech.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechController.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechText.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/model/TtsTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeechTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechControllerTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechTextTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Tts.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Tts.kt new file mode 100644 index 00000000..85ff2e49 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Tts.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +/** + * Request body for `POST /api/audio/speak` — the dashboard's server-side + * text-to-speech relay. + */ +@Serializable +data class TtsSpeakRequest( + val text: String, +) + +/** + * Response of `POST /api/audio/speak`. + * + * Returns base64 encoded [data_url] with [mime_type] and the [provider] used, + * or [ok] = false on failure. + */ +@Serializable +data class TtsSpeakResponse( + val ok: Boolean = false, + val data_url: String? = null, + val mime_type: String? = null, + val provider: String? = null, +) 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 c51156a9..a2a486c6 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 @@ -134,6 +134,8 @@ import com.m57.hermescontrol.data.model.ToolsetPostSetupResponse import com.m57.hermescontrol.data.model.ToolsetProviderSelectRequest import com.m57.hermescontrol.data.model.ToolsetProviderSelectResponse import com.m57.hermescontrol.data.model.ToolsetToggleRequest +import com.m57.hermescontrol.data.model.TtsSpeakRequest +import com.m57.hermescontrol.data.model.TtsSpeakResponse import com.m57.hermescontrol.data.model.UpdateCheckResponse import com.m57.hermescontrol.data.model.UpdateCronJobRequest import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest @@ -1043,4 +1045,10 @@ interface HermesApiService : KanbanApiService { suspend fun transcribeAudio( @Body body: AudioTranscriptionRequest, ): Response + + @POST("api/audio/speak") + suspend fun speakText( + @Body request: TtsSpeakRequest, + @Query("profile") profile: String? = null, + ): Response } 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 49585ae6..6dd3b8e9 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 @@ -84,10 +84,12 @@ 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.local.AuthManager 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.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.notification.NotificationHelper @@ -111,10 +113,13 @@ 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 +import com.m57.hermescontrol.ui.chat.components.SpeechRequest +import com.m57.hermescontrol.ui.chat.components.SpeechText import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet import com.m57.hermescontrol.ui.chat.components.TaskProgressChip import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController +import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList @@ -249,6 +254,10 @@ fun ChatScreen( } val browserEvent = connectorsState.browserLaunchEvent + val speechController = rememberChatSpeech() + val speakingMessageId by speechController.speakingId.collectAsStateWithLifecycle() + val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle() + val activeSessionId by ActiveSessionHolder.activeSessionId.collectAsStateWithLifecycle() val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy) val scrollScope = rememberCoroutineScope() val scrollController = rememberChatScrollController(listState, scrollScope) @@ -799,6 +808,17 @@ fun ChatScreen( savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath, openingAttachmentPath = state.openingAttachmentPath, onImageClick = { viewingImage = it }, + speakingMessageId = speakingMessageId, + onToggleSpeak = { message -> + val scopeKey = "${dataScope?.inMemoryKey(localKey = activeSessionId ?: "none")}" + speechController.toggle( + SpeechRequest( + scopeKey = scopeKey, + messageId = message.id, + text = SpeechText.stripMarkdownForSpeech(message.content), + ), + ) + }, replyErrorContent = state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeech.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeech.kt new file mode 100644 index 00000000..1b7c046c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeech.kt @@ -0,0 +1,288 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.Context +import android.net.Uri +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import com.m57.hermescontrol.data.model.TtsSpeakRequest +import com.m57.hermescontrol.data.remote.HermesApiService +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.util.Base64 +import java.util.UUID + +private const val TTS_CACHE_DIR_NAME = "speech_tts" +private const val TTS_FILE_PREFIX = "tts_" +private const val TTS_STALE_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000L // 24 hours + +class FileSpeechAudio( + val file: File, +) : SpeechAudio { + override fun dispose() { + runCatching { file.delete() } + } +} + +/** + * Android implementation of [SpeechSynthesizer] backed by Hermes TTS REST API. + * + * Audio is decoded from base64 data URLs directly to temporary files in application cache. + * Stale orphan cache files (created in previous sessions or left behind by unexpected crashes) + * older than [TTS_STALE_CACHE_MAX_AGE_MS] are opportunistically pruned during synthesis on the + * IO dispatcher before creating new files. + * + * Limitation note: Single-active controller usage is expected. Active audio files in current use + * by the active session are protected from cleanup by the age threshold, but concurrent multi-process + * or out-of-band manipulation of cache files is not synchronized. + */ +class AndroidSpeechSynthesizer( + context: Context, + private val api: HermesApiService, + private val profile: String?, +) : SpeechSynthesizer { + private val appContext = context.applicationContext + + override suspend fun synthesize(request: SpeechRequest): SpeechAudio { + val response = + api.speakText( + request = TtsSpeakRequest(request.text), + profile = profile, + ) + + if (!response.isSuccessful) { + throw IOException("TTS request failed with code ${response.code()}") + } + + val body = response.body() ?: throw IOException("Empty TTS response body") + if (!body.ok) { + throw IOException("TTS synthesis reported failure") + } + + val dataUrl = body.data_url + if (dataUrl.isNullOrBlank()) { + throw IOException("Missing data URL in TTS response") + } + + var ownedFile: File? = null + try { + val audioFile = + withContext(Dispatchers.IO) { + val comma = dataUrl.indexOf(',') + if (comma < 0) { + throw IOException("Malformed data URL in TTS response") + } + + val meta = dataUrl.substring(0, comma) + if (!meta.startsWith("data:audio/", ignoreCase = true)) { + throw IOException("Expected audio data URL") + } + if (!meta.endsWith(";base64", ignoreCase = true)) { + throw IOException("Expected base64-encoded audio data URL") + } + + val ext = + when { + meta.contains( + "audio/mpeg", + ignoreCase = true, + ) || meta.contains("audio/mp3", ignoreCase = true) -> "mp3" + + meta.contains("audio/wav", ignoreCase = true) -> "wav" + + meta.contains("audio/ogg", ignoreCase = true) -> "ogg" + + meta.contains("audio/aac", ignoreCase = true) -> "aac" + + meta.contains("audio/opus", ignoreCase = true) -> "opus" + + meta.contains("audio/flac", ignoreCase = true) -> "flac" + + else -> "audio" + } + + val cacheDir = File(appContext.cacheDir, TTS_CACHE_DIR_NAME).apply { mkdirs() } + cleanStaleCacheFiles(cacheDir) + + val file = File(cacheDir, "${TTS_FILE_PREFIX}${UUID.randomUUID()}.$ext") + ownedFile = file + + val rawStream = CharSequenceInputStream(dataUrl, comma + 1) + val decodingStream = Base64.getDecoder().wrap(rawStream) + + var totalBytesWritten = 0L + decodingStream.use { input -> + FileOutputStream(file).use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + currentCoroutineContext().ensureActive() + output.write(buffer, 0, bytesRead) + totalBytesWritten += bytesRead + } + output.flush() + } + } + + // Java's Base64.getDecoder().wrap() stops decoding upon encountering padding ('='). + // Verify that the underlying stream contains no unparsed trailing non-whitespace characters. + if (rawStream.read() != -1) { + throw IOException("Trailing garbage characters found after base64 data") + } + + if (totalBytesWritten == 0L) { + throw IOException("Decoded audio file is empty") + } + + file + } + + ownedFile = null + return FileSpeechAudio(audioFile) + } catch (e: CancellationException) { + ownedFile?.let { runCatching { it.delete() } } + throw e + } catch (t: Throwable) { + ownedFile?.let { runCatching { it.delete() } } + throw t + } + } + + private fun cleanStaleCacheFiles(cacheDir: File) { + val now = System.currentTimeMillis() + val files = + cacheDir.listFiles { _, name -> + name.startsWith(TTS_FILE_PREFIX) + } ?: return + + for (file in files) { + if (now - file.lastModified() > TTS_STALE_CACHE_MAX_AGE_MS) { + runCatching { file.delete() } + } + } + } + + private class CharSequenceInputStream( + private val value: CharSequence, + startIndex: Int, + ) : InputStream() { + private var index = startIndex + + override fun read(): Int { + while (index < value.length) { + val c = value[index++] + if (!c.isWhitespace()) { + if (c.code > 127) { + throw IOException("Non-ASCII character in base64 data") + } + return c.code + } + } + return -1 + } + + override fun read( + b: ByteArray, + off: Int, + len: Int, + ): Int { + if (len == 0) return 0 + var bytesRead = 0 + while (bytesRead < len) { + val c = read() + if (c == -1) { + return if (bytesRead == 0) -1 else bytesRead + } + b[off + bytesRead] = c.toByte() + bytesRead++ + } + return bytesRead + } + } +} + +class AndroidSpeechPlayer( + context: Context, +) : SpeechPlayer { + private val appContext = context.applicationContext + private var exoPlayer: ExoPlayer? = null + private var activeListener: Player.Listener? = null + + override fun play( + audio: SpeechAudio, + onEnded: () -> Unit, + onError: () -> Unit, + ) { + if (audio !is FileSpeechAudio) { + onError() + return + } + + val file = audio.file + if (!file.exists() || file.length() == 0L) { + onError() + return + } + + val player = + exoPlayer ?: ExoPlayer + .Builder(appContext) + .setAudioAttributes( + AudioAttributes + .Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) + .setUsage(C.USAGE_MEDIA) + .build(), + true, + ).setHandleAudioBecomingNoisy(true) + .build() + .also { exoPlayer = it } + + activeListener?.let { player.removeListener(it) } + + val listener = + object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_ENDED) { + player.removeListener(this) + activeListener = null + onEnded() + } + } + + override fun onPlayerError(error: PlaybackException) { + player.removeListener(this) + activeListener = null + onError() + } + } + + activeListener = listener + player.addListener(listener) + + val mediaItem = MediaItem.fromUri(Uri.fromFile(file)) + player.setMediaItem(mediaItem) + player.prepare() + player.playWhenReady = true + } + + override fun stop() { + val player = exoPlayer ?: return + activeListener?.let { player.removeListener(it) } + activeListener = null + player.stop() + player.clearMediaItems() + player.release() + exoPlayer = null + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/RememberChatSpeech.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/RememberChatSpeech.kt new file mode 100644 index 00000000..9ce3769d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/RememberChatSpeech.kt @@ -0,0 +1,99 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.session.ActiveSessionHolder + +/** + * Creates and remembers a screen/list-level [SpeechController] scoped to the active + * chat session, server endpoint, profile, and auth state. + * + * Handles lifecycle events: + * - Resets (stops playback and cleans cache) on [Lifecycle.Event.ON_STOP] + * - Resets on composition disposal or when the server endpoint, profile, auth state, or session changes + */ +@Composable +fun rememberChatSpeech( + synthesizer: SpeechSynthesizer? = null, + player: SpeechPlayer? = null, + onError: (() -> Unit)? = null, +): SpeechController { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val lifecycleOwner = LocalLifecycleOwner.current + val errorSynthesisMessage = stringResource(R.string.tts_error_synthesis) + + val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle() + val activeSessionId by ActiveSessionHolder.activeSessionId.collectAsStateWithLifecycle() + + val profile = dataScope?.activeProfileId + val scopeKey = dataScope?.inMemoryKey(localKey = activeSessionId ?: "none").orEmpty() + + val resolvedSynthesizer = + remember(synthesizer, dataScope) { + synthesizer ?: AndroidSpeechSynthesizer( + context = context, + api = ApiClient.hermesApi, + profile = profile, + ) + } + + val resolvedPlayer = + remember(player, context) { + player ?: AndroidSpeechPlayer(context) + } + + val controller = + remember(scope, resolvedSynthesizer, resolvedPlayer, errorSynthesisMessage) { + SpeechController( + scope = scope, + synthesizer = resolvedSynthesizer, + player = resolvedPlayer, + onError = { + onError?.invoke() ?: Toast + .makeText( + context, + errorSynthesisMessage, + Toast.LENGTH_SHORT, + ).show() + }, + ) + } + + // Reset when session scope changes (session/profile/server/auth switch) + DisposableEffect(controller, scopeKey) { + onDispose { + controller.reset() + } + } + + // Reset on ON_STOP lifecycle event + DisposableEffect(lifecycleOwner, controller) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) { + controller.reset() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + controller.reset() + } + } + + return controller +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechController.kt new file mode 100644 index 00000000..88f7958c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechController.kt @@ -0,0 +1,178 @@ +package com.m57.hermescontrol.ui.chat.components + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class SpeechRequest( + val scopeKey: String, + val messageId: String, + val text: String, +) + +interface SpeechAudio { + fun dispose() +} + +interface SpeechSynthesizer { + suspend fun synthesize(request: SpeechRequest): SpeechAudio +} + +interface SpeechPlayer { + fun play( + audio: SpeechAudio, + onEnded: () -> Unit, + onError: () -> Unit, + ) + + fun stop() +} + +class SpeechController( + private val scope: CoroutineScope, + private val synthesizer: SpeechSynthesizer, + private val player: SpeechPlayer, + private val onError: () -> Unit, +) { + private val _speakingId = MutableStateFlow(null) + val speakingId: StateFlow = _speakingId.asStateFlow() + + private var activeRequest: SpeechRequest? = null + private var activeJob: Job? = null + private var generation: Long = 0L + + private var cachedRequest: SpeechRequest? = null + private var cachedAudio: SpeechAudio? = null + + fun toggle(request: SpeechRequest) { + if (request.text.isBlank()) { + return + } + + if (activeRequest == request) { + stop() + return + } + + start(request) + } + + fun stop() { + generation++ + activeJob?.cancel() + activeJob = null + activeRequest = null + player.stop() + _speakingId.value = null + } + + fun reset() { + stop() + disposeCache() + } + + private fun start(request: SpeechRequest) { + generation++ + val currentGen = generation + + activeJob?.cancel() + player.stop() + + if (cachedRequest != null && cachedRequest != request) { + disposeCache() + } + + activeRequest = request + _speakingId.value = request.messageId + + val cached = cachedAudio + if (cached != null && cachedRequest == request) { + playAudio(cached, currentGen) + return + } + + activeJob = + scope.launch { + var synthesizedAudio: SpeechAudio? = null + try { + val audio = synthesizer.synthesize(request) + synthesizedAudio = audio + + if (generation != currentGen) { + audio.dispose() + return@launch + } + + cachedAudio?.dispose() + cachedRequest = request + cachedAudio = audio + synthesizedAudio = null + + playAudio(audio, currentGen) + } catch (e: CancellationException) { + synthesizedAudio?.dispose() + throw e + } catch (e: Exception) { + synthesizedAudio?.dispose() + if (generation == currentGen) { + handlePlaybackFailure() + } + } + } + } + + private fun playAudio( + audio: SpeechAudio, + gen: Long, + ) { + try { + var completed = false + player.play( + audio = audio, + onEnded = { + if (generation == gen && !completed) { + completed = true + handlePlaybackFinished() + } + }, + onError = { + if (generation == gen && !completed) { + completed = true + handlePlaybackFailure() + } + }, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (generation == gen) { + handlePlaybackFailure() + } + } + } + + private fun handlePlaybackFinished() { + activeJob = null + activeRequest = null + player.stop() + _speakingId.value = null + } + + private fun handlePlaybackFailure() { + activeJob = null + activeRequest = null + player.stop() + _speakingId.value = null + onError() + } + + private fun disposeCache() { + cachedAudio?.dispose() + cachedAudio = null + cachedRequest = null + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechText.kt new file mode 100644 index 00000000..ad0668b9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechText.kt @@ -0,0 +1,118 @@ +package com.m57.hermescontrol.ui.chat.components + +/** + * Plain-text preparation for per-message read-aloud (TTS speak button). + * + * Pure Kotlin — no Android imports — so it stays unit-testable on the JVM. + * Strips Markdown formatting while preserving readable prose, inline code, + * fenced code contents, link titles/alt texts, and Unicode/emojis. + */ +object SpeechText { + // Fenced code blocks: ``` or ~~~ with optional info string, spanning to closing fence or EOF. + private val FENCED_CODE_BLOCK_REGEX = + Regex("(?m)^[ \\t]*(`{3,}|~{3,})[^\\r\\n`~]*\\r?\\n([\\s\\S]*?)(?:^[ \\t]*\\1[ \\t]*(?:\\r?\\n|\\Z)|\\Z)") + + // Placeholders with null byte to guarantee safety from standard markdown text + private const val CODE_SPAN_PREFIX = "\u0000HERMESCODE" + private const val CODE_SPAN_SUFFIX = "\u0000" + + // Inline code: `code` or multiple backticks ``code`` + private val INLINE_CODE_REGEX = Regex("(`+)(.+?)\\1") + + // Markdown structures + private val IMAGE_LINK_REGEX = Regex("!\\[([^\\]]*)\\]\\([^)]*\\)") + private val LINK_REGEX = Regex("\\[([^\\]]*)\\]\\([^)]*\\)") + private val URL_REGEX = Regex("https?://\\S+") + private val HTML_TAG_REGEX = Regex("<[^>]+>") + + // Block elements (multiline) + private val HEADING_REGEX = Regex("(?m)^\\s{0,3}#{1,6}\\s+") + private val BLOCKQUOTE_REGEX = Regex("(?m)^\\s*>\\s?") + private val LIST_MARKER_REGEX = Regex("(?m)^\\s*(?:[-*+]|\\d+[.)])\\s+") + private val HORIZONTAL_RULE_REGEX = Regex("(?m)^\\s*([-_*])(?:\\s*\\1){2,}\\s*$") + + // Emphasis / strikethrough markers + private val BOLD_REGEX = Regex("(\\*\\*|__)(.*?)\\1") + private val ITALIC_REGEX = Regex("(\\*|_)(.*?)\\1") + private val STRIKETHROUGH_REGEX = Regex("~~(.*?)~~") + + // Punctuation set including Arabic question mark (\u061F) and semicolon (\u061B), and standard ones + // Punctuations that can end a sentence or pause: . , ! ? ; : ؟ ؛ + private const val PUNCTUATION_CLASS = "[.,!?;:\\u061F\\u061B]" + private val PUNCT_BEFORE_NEWLINE_REGEX = Regex("($PUNCTUATION_CLASS)[^\\S\\r\\n]*(?:\\r?\\n[^\\S\\r\\n]*)+") + private val PLAIN_NEWLINE_REGEX = Regex("[^\\S\\r\\n]*(?:\\r?\\n[^\\S\\r\\n]*)+") + private val WHITESPACE_REGEX = Regex("\\s+") + private val SPACE_BEFORE_PUNCT_REGEX = Regex("\\s+($PUNCTUATION_CLASS)") + + fun stripMarkdownForSpeech(text: String): String { + if (text.isBlank()) return "" + + val codeSpans = mutableListOf() + + // 1. Protect fenced code blocks before prose processing. + // Delimiters and info string are stripped; code content whitespace is normalized for speech. + var out = + FENCED_CODE_BLOCK_REGEX.replace(text) { matchResult -> + val rawContent = matchResult.groupValues[2] + val normalizedContent = + rawContent + .trim() + .split( + WHITESPACE_REGEX, + ).filter { it.isNotEmpty() } + .joinToString(" ") + val index = codeSpans.size + codeSpans.add(normalizedContent) + "\n$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX\n" + } + + // 2. Protect inline code spans before prose processing. + out = + INLINE_CODE_REGEX.replace(out) { matchResult -> + val content = matchResult.groupValues[2].trim() + val index = codeSpans.size + codeSpans.add(content) + "$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX" + } + + // 3. Process prose markdown elements + // Images: keep alt text, drop the URL. + out = out.replace(IMAGE_LINK_REGEX, "$1") + // Links: keep visible text, drop the target. + out = out.replace(LINK_REGEX, "$1") + // Autolinks / bare URLs: drop the URL, keep surrounding prose. + out = out.replace(URL_REGEX, " ") + // HTML tags. + out = out.replace(HTML_TAG_REGEX, " ") + // Headings, blockquotes, list markers, horizontal rules. + out = out.replace(HEADING_REGEX, "") + out = out.replace(BLOCKQUOTE_REGEX, "") + out = out.replace(LIST_MARKER_REGEX, "") + out = out.replace(HORIZONTAL_RULE_REGEX, " ") + // Table pipes + out = out.replace("|", " ") + // Emphasis / strikethrough markers + out = out.replace(BOLD_REGEX, "$2") + out = out.replace(ITALIC_REGEX, "$2") + out = out.replace(STRIKETHROUGH_REGEX, "$1") + + // 4. Normalise newlines into sensible pauses without accidental global punctuation mutation. + // If line ends with punctuation, keep it with a trailing space. + // Otherwise, replace newline with ". " to create a pause. + out = out.replace(PUNCT_BEFORE_NEWLINE_REGEX, "$1 ") + out = out.replace(PLAIN_NEWLINE_REGEX, ". ") + + // 5. Restore protected code spans (both fenced and inline) + for ((index, code) in codeSpans.withIndex()) { + out = out.replace("$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX", code) + } + + // 6. Collapse multiple whitespaces and trim + out = out.replace(WHITESPACE_REGEX, " ").trim() + + // 7. Tidy spaces before punctuation left behind by marker removal. + out = out.replace(SPACE_BEFORE_PUNCT_REGEX, "$1") + + return out + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 604d7fae..8899c7d4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -13,8 +13,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -65,6 +67,8 @@ internal fun FullBleedAgentMessage( openingAttachmentPath: String? = null, canSaveAttachment: Boolean = true, onImageClick: (ImageViewerModel) -> Unit = {}, + isSpeaking: Boolean = false, + onToggleSpeak: (() -> Unit)? = null, messageStatsEnabled: Boolean = false, showAssistantMessageTokens: Boolean = true, showTokensPerSecond: Boolean = true, @@ -162,6 +166,31 @@ internal fun FullBleedAgentMessage( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } + if (onToggleSpeak != null) { + IconButton( + onClick = onToggleSpeak, + modifier = Modifier.size(28.dp).testTag("fullbleed_speak"), + ) { + Icon( + imageVector = + if (isSpeaking) { + Icons.Filled.Stop + } else { + Icons.AutoMirrored.Filled.VolumeUp + }, + contentDescription = + stringResource( + if (isSpeaking) { + R.string.content_desc_stop_speaking + } else { + R.string.content_desc_speak + }, + ), + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } if (showTokenStat) { AssistantStatItem( value = 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 431b0195..6d0bb55b 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 @@ -105,6 +105,8 @@ fun FullBleedChatList( pagingSessionId: String? = null, onLoadOlder: () -> Unit = viewModel::loadOlderMessages, replyErrorContent: (@Composable () -> Unit)? = null, + speakingMessageId: String? = null, + onToggleSpeak: ((ChatMessage) -> Unit)? = null, ) { if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) { Box( @@ -363,6 +365,15 @@ fun FullBleedChatList( showReasoning = !hoistedReasoning, ) } else { + val isSpeakingThis = + speakingMessageId != null && + speakingMessageId == proseMessage.id + val toggleSpeakAction = + if (onToggleSpeak != null) { + { onToggleSpeak(proseMessage) } + } else { + null + } FullBleedAgentMessage( message = proseMessage, // Highlight only bubbles that actually contain a match — @@ -385,6 +396,8 @@ fun FullBleedChatList( openingAttachmentPath = openingAttachmentPath, canSaveAttachment = savingAttachmentPath == null, onImageClick = onImageClick, + isSpeaking = isSpeakingThis, + onToggleSpeak = toggleSpeakAction, messageStatsEnabled = messageStatsEnabled, showAssistantMessageTokens = showAssistantMessageTokens, showTokensPerSecond = showTokensPerSecond, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae26e415..c05a133d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -237,6 +237,11 @@ Scroll to bottom Copy Copied + Read aloud + Stop speaking + Could not reach the Hermes server for speech + Could not synthesize the message audio + Could not play the message audio Edit %1$s tok %1$s t/s diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/TtsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/TtsTest.kt new file mode 100644 index 00000000..1c0f3515 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/TtsTest.kt @@ -0,0 +1,140 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TtsTest { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + @Test + fun `serialize TtsSpeakRequest with literal text`() { + val request = TtsSpeakRequest(text = "Hello, world!") + val encoded = json.encodeToString(request) + + assertEquals("""{"text":"Hello, world!"}""", encoded) + } + + @Test + fun `deserialize TtsSpeakResponse with all fields present`() { + val rawJson = + """ + { + "ok": true, + "data_url": "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=", + "mime_type": "audio/wav", + "provider": "piper" + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertTrue(response.ok) + assertEquals( + "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=", + response.data_url, + ) + assertEquals("audio/wav", response.mime_type) + assertEquals("piper", response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with optional provider missing`() { + val rawJson = + """ + { + "ok": true, + "data_url": "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/", + "mime_type": "audio/mp3" + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertTrue(response.ok) + assertEquals( + "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/", + response.data_url, + ) + assertEquals("audio/mp3", response.mime_type) + assertNull(response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with provider explicitly null`() { + val rawJson = + """ + { + "ok": true, + "data_url": "data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=", + "mime_type": "audio/ogg", + "provider": null + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertTrue(response.ok) + assertEquals("data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=", response.data_url) + assertEquals("audio/ogg", response.mime_type) + assertNull(response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with missing ok defaults to false`() { + val rawJson = + """ + { + "data_url": null, + "mime_type": null + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertFalse(response.ok) + assertNull(response.data_url) + assertNull(response.mime_type) + assertNull(response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with empty json defaults to ok false and nulls`() { + val rawJson = "{}" + + val response = json.decodeFromString(rawJson) + + assertFalse(response.ok) + assertNull(response.data_url) + assertNull(response.mime_type) + assertNull(response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with unknown fields ignores extra properties`() { + val rawJson = + """ + { + "ok": false, + "error": "Provider not reachable", + "status_code": 500 + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertFalse(response.ok) + assertNull(response.data_url) + assertNull(response.mime_type) + assertNull(response.provider) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeechTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeechTest.kt new file mode 100644 index 00000000..9136b061 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeechTest.kt @@ -0,0 +1,458 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.Context +import com.m57.hermescontrol.data.model.TtsSpeakRequest +import com.m57.hermescontrol.data.model.TtsSpeakResponse +import com.m57.hermescontrol.data.remote.HermesApiService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import retrofit2.Response +import java.io.File +import java.io.IOException +import java.util.Base64 + +@OptIn(ExperimentalCoroutinesApi::class) +class AndroidSpeechTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private fun createContext(): Context { + val mockContext = mockk() + every { mockContext.applicationContext } returns mockContext + every { mockContext.cacheDir } returns tempFolder.root + return mockContext + } + + private fun listTempFiles(): List = + tempFolder.root + .walkTopDown() + .filter { it.isFile } + .toList() + + @Test + fun `synthesize decodes small real base64 audio bytes to exact file bytes and passes text and profile`() = + runTest { + val context = createContext() + val api = mockk() + val expectedBytes = byteArrayOf(0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00) + val base64Data = Base64.getEncoder().encodeToString(expectedBytes) + val dataUrl = "data:audio/wav;base64,$base64Data" + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = dataUrl, + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = "work-profile", + ) + + val request = + SpeechRequest( + scopeKey = "session-42", + messageId = "msg-101", + text = "Hello world", + ) + + val resultAudio: SpeechAudio = synthesizer.synthesize(request) + assertTrue("Expected result to be FileSpeechAudio", resultAudio is FileSpeechAudio) + + val fileAudio = resultAudio as FileSpeechAudio + val audioFile = fileAudio.file + + assertTrue("Synthesized audio file must exist", audioFile.exists()) + assertArrayEquals("Decoded file bytes must match input bytes", expectedBytes, audioFile.readBytes()) + + coVerify(exactly = 1) { + api.speakText( + request = TtsSpeakRequest(text = "Hello world"), + profile = "work-profile", + ) + } + + fileAudio.dispose() + assertFalse("File must be deleted after dispose()", audioFile.exists()) + } + + @Test + fun `synthesize passes null profile when synthesizer constructed without profile`() = + runTest { + val context = createContext() + val api = mockk() + val rawBytes = byteArrayOf(0x01, 0x02, 0x03, 0x04) + val base64Data = Base64.getEncoder().encodeToString(rawBytes) + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:audio/mp3;base64,$base64Data", + mime_type = "audio/mp3", + provider = null, + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "session-1", + messageId = "msg-1", + text = "Testing null profile", + ) + + val audio = synthesizer.synthesize(request) + assertTrue(audio is FileSpeechAudio) + + coVerify(exactly = 1) { + api.speakText( + request = TtsSpeakRequest(text = "Testing null profile"), + profile = null, + ) + } + + audio.dispose() + assertEquals(0, listTempFiles().size) + } + + @Test + fun `HTTP or network failure throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } throws IOException("Network connection lost") + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = "test-profile", + ) + + val request = + SpeechRequest( + scopeKey = "session-err", + messageId = "msg-err", + text = "Network fail", + ) + + try { + synthesizer.synthesize(request) + fail("Expected IOException to be thrown") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after network failure", 0, listTempFiles().size) + } + + @Test + fun `response with ok false throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = false, + data_url = null, + mime_type = null, + provider = null, + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = "profile-1", + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Failed ok=false", + ) + + try { + synthesizer.synthesize(request) + fail("Expected synthesize to fail when ok is false") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after ok=false", 0, listTempFiles().size) + } + + @Test + fun `malformed data URI scheme throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "not-a-data-uri", + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Malformed scheme", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for malformed data URI") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after malformed URI failure", 0, listTempFiles().size) + } + + @Test + fun `non-audio data URI throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + val textBase64 = Base64.getEncoder().encodeToString("hello world".toByteArray()) + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:text/plain;base64,$textBase64", + mime_type = "text/plain", + provider = "text", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Non audio data URI", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for non-audio data URI") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after non-audio data URI failure", 0, listTempFiles().size) + } + + @Test + fun `invalid base64 payload throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:audio/wav;base64,!!!NotBase64Chars!!!", + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Invalid base64", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for invalid base64") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after base64 decoding failure", 0, listTempFiles().size) + } + + @Test + fun `empty data URI payload throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:audio/wav;base64,", + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Empty payload", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for empty base64 payload") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after empty payload failure", 0, listTempFiles().size) + } + + @Test + fun `null data_url throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = null, + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Null data url", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure when data_url is null") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after null data_url failure", 0, listTempFiles().size) + } + + @Test + fun `padded valid base64 followed by garbage throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + val validPaddedBase64 = "AQ==" // 1 byte decoded (0x01), padded with == + val payloadWithTrailingGarbage = validPaddedBase64 + "garbage" + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:audio/wav;base64,$payloadWithTrailingGarbage", + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Garbage after padding", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for base64 with trailing garbage after padding") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after trailing garbage failure", 0, listTempFiles().size) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechControllerTest.kt new file mode 100644 index 00000000..92e04e14 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechControllerTest.kt @@ -0,0 +1,738 @@ +package com.m57.hermescontrol.ui.chat.components + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SpeechControllerTest { + private class FakeAudio( + val tag: String, + ) : SpeechAudio { + var isDisposed: Boolean = false + private set + + override fun dispose() { + isDisposed = true + } + } + + private class FakeSynthesizer : SpeechSynthesizer { + var calls: MutableList = mutableListOf() + var synthesizeBlock: (suspend (SpeechRequest) -> SpeechAudio)? = null + + override suspend fun synthesize(request: SpeechRequest): SpeechAudio { + calls.add(request) + return synthesizeBlock?.invoke(request) ?: FakeAudio(request.text) + } + } + + private class FakePlayer : SpeechPlayer { + var currentlyPlaying: SpeechAudio? = null + var lastEndedCallback: (() -> Unit)? = null + var lastErrorCallback: (() -> Unit)? = null + var playCallCount: Int = 0 + var stopCallCount: Int = 0 + var playThrows: Throwable? = null + + override fun play( + audio: SpeechAudio, + onEnded: () -> Unit, + onError: () -> Unit, + ) { + playCallCount++ + currentlyPlaying = audio + lastEndedCallback = onEnded + lastErrorCallback = onError + playThrows?.let { throw it } + } + + override fun stop() { + stopCallCount++ + currentlyPlaying = null + lastEndedCallback = null + lastErrorCallback = null + } + } + + private class Fixture( + val scope: TestScope, + ) { + val synthesizer = FakeSynthesizer() + val player = FakePlayer() + val errors = mutableListOf() + + val controller = + SpeechController( + scope = scope, + synthesizer = synthesizer, + player = player, + onError = { errors.add(Unit) }, + ) + } + + @Test + fun `toggle with blank text is a no-op`() = + runTest { + val fixture = Fixture(this) + val blankReq = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = " ") + + fixture.controller.toggle(blankReq) + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.synthesizer.calls.size) + assertEquals(0, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `toggle with empty text is a no-op`() = + runTest { + val fixture = Fixture(this) + val emptyReq = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "") + + fixture.controller.toggle(emptyReq) + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.synthesizer.calls.size) + assertEquals(0, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `toggle synthesizes and plays audio, setting speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(req, fixture.synthesizer.calls[0]) + assertEquals(1, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `toggle on same active request stops playback and clears speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + fixture.controller.toggle(req) + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertTrue(fixture.player.stopCallCount >= 1) + } + + @Test + fun `toggle on same active request while still synthesizing stops and cancels synthesis`() = + runTest { + val fixture = Fixture(this) + val gate = CompletableDeferred() + fixture.synthesizer.synthesizeBlock = { gate.await() } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + fixture.controller.toggle(req) + runCurrent() + + // Toggle same request while synthesis is inflight + fixture.controller.toggle(req) + runCurrent() + + assertNull(fixture.controller.speakingId.value) + + // Even if synthesis completes later, generation fencing ensures it never plays + val audio = FakeAudio("Hello world") + gate.complete(audio) + advanceUntilIdle() + + assertEquals(0, fixture.player.playCallCount) + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `noncooperative synthesis cancelled during synthesis still disposes returned audio without playing`() = + runTest { + val fixture = Fixture(this) + val gate = CompletableDeferred() + val resumeGate = CompletableDeferred() + val audio = FakeAudio("Stale noncooperative audio") + + fixture.synthesizer.synthesizeBlock = { + try { + gate.await() + } catch (t: Throwable) { + withContext(NonCancellable) { + resumeGate.await() + } + } + audio + } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + fixture.controller.toggle(req) + runCurrent() + + // Cancel by stopping + fixture.controller.stop() + runCurrent() + assertNull(fixture.controller.speakingId.value) + + // Now let noncooperative synthesis produce audio after cancellation + resumeGate.complete(Unit) + advanceUntilIdle() + + // Audio must be disposed, never played, no error emitted, and speakingId remains null + assertTrue(audio.isDisposed) + assertEquals(0, fixture.player.playCallCount) + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `noncooperative synthesis superseded by replacement disposes stale audio without touching newer state`() = + runTest { + val fixture = Fixture(this) + val gate1 = CompletableDeferred() + val resumeGate1 = CompletableDeferred() + val audio1 = FakeAudio("Stale noncooperative audio 1") + val audio2 = FakeAudio("New active audio 2") + + fixture.synthesizer.synthesizeBlock = { req -> + if (req.messageId == "msg-1") { + try { + gate1.await() + } catch (t: Throwable) { + withContext(NonCancellable) { + resumeGate1.await() + } + } + audio1 + } else { + audio2 + } + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text") + + fixture.controller.toggle(req1) + runCurrent() + + // Supersede with req2 + fixture.controller.toggle(req2) + advanceUntilIdle() + + // req2 should now be playing + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(1, fixture.player.playCallCount) + + // Now release stale noncooperative synthesis from req1 + resumeGate1.complete(Unit) + advanceUntilIdle() + + // Stale audio1 must be disposed, no extra play calls, speakingId untouched, no errors + assertTrue(audio1.isDisposed) + assertFalse(audio2.isDisposed) + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(1, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `noncooperative synthesis throwing failure after replacement is ignored and does not invoke onError`() = + runTest { + val fixture = Fixture(this) + val gate1 = CompletableDeferred() + val resumeGate1 = CompletableDeferred() + val audio2 = FakeAudio("Active audio 2") + + fixture.synthesizer.synthesizeBlock = { req -> + if (req.messageId == "msg-1") { + try { + gate1.await() + } catch (t: Throwable) { + withContext(NonCancellable) { + resumeGate1.await() + } + } + throw IllegalStateException("Stale synthesis explosion") + } else { + audio2 + } + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text") + + fixture.controller.toggle(req1) + runCurrent() + + // Replace with req2 + fixture.controller.toggle(req2) + advanceUntilIdle() + + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + + // Release stale failure + resumeGate1.complete(Unit) + advanceUntilIdle() + + // Stale failure must not invoke onError or clear msg-2 + assertEquals(0, fixture.errors.size) + assertEquals("msg-2", fixture.controller.speakingId.value) + } + + @Test + fun `stop and restart race on same request ensures stale inflight completion does not disrupt new cycle`() = + runTest { + val fixture = Fixture(this) + val gate1 = CompletableDeferred() + val resumeGate1 = CompletableDeferred() + val audio1 = FakeAudio("Cycle 1 audio") + val audio2 = FakeAudio("Cycle 2 audio") + + var callIndex = 0 + fixture.synthesizer.synthesizeBlock = { + val idx = ++callIndex + if (idx == 1) { + try { + gate1.await() + } catch (t: Throwable) { + withContext(NonCancellable) { + resumeGate1.await() + } + } + audio1 + } else { + audio2 + } + } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Same text") + + // Cycle 1: start synthesis + fixture.controller.toggle(req) + runCurrent() + + // Toggle same request while synthesizing -> cancels cycle 1 + fixture.controller.toggle(req) + runCurrent() + assertNull(fixture.controller.speakingId.value) + + // Cycle 2: restart SAME request immediately + fixture.controller.toggle(req) + advanceUntilIdle() + + // Cycle 2 has synthesized audio2 and is playing it + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(1, fixture.player.playCallCount) + + // Stale cycle 1 finally returns audio1 + resumeGate1.complete(Unit) + advanceUntilIdle() + + // Stale audio1 must be disposed and cycle 2 remains active and playing audio2 + assertTrue(audio1.isDisposed) + assertFalse(audio2.isDisposed) + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(1, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `stop stops playback and clears speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + val stopCallsBefore = fixture.player.stopCallCount + fixture.controller.stop() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertTrue(fixture.player.stopCallCount > stopCallsBefore) + } + + @Test + fun `replacement immediately stops active playback and cancels old request`() = + runTest { + val fixture = Fixture(this) + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text") + + fixture.controller.toggle(req1) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + fixture.controller.toggle(req2) + advanceUntilIdle() + + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(2, fixture.synthesizer.calls.size) + assertEquals(2, fixture.player.playCallCount) + } + + @Test + fun `replacement cancels inflight synthesis and generation fencing rejects stale completion`() = + runTest { + val fixture = Fixture(this) + val gate1 = CompletableDeferred() + val gate2 = CompletableDeferred() + + fixture.synthesizer.synthesizeBlock = { req -> + if (req.messageId == "msg-1") gate1.await() else gate2.await() + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text") + + fixture.controller.toggle(req1) + runCurrent() + + fixture.controller.toggle(req2) + runCurrent() + + // Complete old stale synthesis + val audio1 = FakeAudio("First text") + gate1.complete(audio1) + runCurrent() + + // Complete new synthesis + val audio2 = FakeAudio("Second text") + gate2.complete(audio2) + advanceUntilIdle() + + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(1, fixture.player.playCallCount) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `player onEnded callback resets speakingId to null`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + val onEnded = fixture.player.lastEndedCallback + onEnded?.invoke() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `stale onEnded callback from superseded generation does not clear active speakingId`() = + runTest { + val fixture = Fixture(this) + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second") + + fixture.controller.toggle(req1) + advanceUntilIdle() + val staleOnEnded = fixture.player.lastEndedCallback + + fixture.controller.toggle(req2) + advanceUntilIdle() + assertEquals("msg-2", fixture.controller.speakingId.value) + + staleOnEnded?.invoke() + advanceUntilIdle() + + // Still speaking msg-2! + assertEquals("msg-2", fixture.controller.speakingId.value) + } + + @Test + fun `stale onError callback from superseded generation is ignored and does not invoke onError`() = + runTest { + val fixture = Fixture(this) + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second") + + fixture.controller.toggle(req1) + advanceUntilIdle() + val staleOnError = fixture.player.lastErrorCallback + + fixture.controller.toggle(req2) + advanceUntilIdle() + assertEquals("msg-2", fixture.controller.speakingId.value) + + staleOnError?.invoke() + advanceUntilIdle() + + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `player onError callback clears speakingId and triggers onError`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + val onError = fixture.player.lastErrorCallback + onError?.invoke() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(1, fixture.errors.size) + } + + @Test + fun `synthesizer failure triggers onError and clears speakingId`() = + runTest { + val fixture = Fixture(this) + fixture.synthesizer.synthesizeBlock = { + throw IllegalStateException("Synthesis failed") + } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + fixture.controller.toggle(req) + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(1, fixture.errors.size) + assertEquals(0, fixture.player.playCallCount) + } + + @Test + fun `cancellations during synthesis do not trigger onError`() = + runTest { + val fixture = Fixture(this) + val gate = CompletableDeferred() + fixture.synthesizer.synthesizeBlock = { gate.await() } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + fixture.controller.toggle(req) + runCurrent() + + // Stop cancels the inflight coroutine + fixture.controller.stop() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `replaying same request uses cached audio without re-synthesizing and stop preserves cache`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Cached audio") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(1, fixture.player.playCallCount) + + // Stop playback — must preserve replay cache + fixture.controller.stop() + advanceUntilIdle() + assertNull(fixture.controller.speakingId.value) + + // Toggle again with same request -> should replay from cache without re-synthesizing + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(2, fixture.player.playCallCount) + } + + @Test + fun `request with different text invalidates cache and disposes old audio`() = + runTest { + val fixture = Fixture(this) + val audio1 = FakeAudio("Text 1") + val audio2 = FakeAudio("Text 2") + fixture.synthesizer.synthesizeBlock = { req -> + if (req.text == "Text 1") audio1 else audio2 + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Text 1") + fixture.controller.toggle(req1) + advanceUntilIdle() + assertEquals(audio1, fixture.player.currentlyPlaying) + assertFalse(audio1.isDisposed) + + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Text 2") + fixture.controller.toggle(req2) + advanceUntilIdle() + + assertEquals(audio2, fixture.player.currentlyPlaying) + assertTrue(audio1.isDisposed) + assertEquals(2, fixture.synthesizer.calls.size) + } + + @Test + fun `request with different scope invalidates cache and disposes old audio`() = + runTest { + val fixture = Fixture(this) + val audio1 = FakeAudio("Scope 1 audio") + val audio2 = FakeAudio("Scope 2 audio") + fixture.synthesizer.synthesizeBlock = { req -> + if (req.scopeKey == "session-1") audio1 else audio2 + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello") + fixture.controller.toggle(req1) + advanceUntilIdle() + assertFalse(audio1.isDisposed) + + val req2 = SpeechRequest(scopeKey = "session-2", messageId = "msg-1", text = "Hello") + fixture.controller.toggle(req2) + advanceUntilIdle() + + assertEquals(audio2, fixture.player.currentlyPlaying) + assertTrue(audio1.isDisposed) + assertEquals(2, fixture.synthesizer.calls.size) + } + + @Test + fun `replaying cached request when player throws does not escape toggle and clears speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Cached text") + + // Initial successful playback + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(1, fixture.player.playCallCount) + + // Complete initial playback + fixture.player.lastEndedCallback?.invoke() + advanceUntilIdle() + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + + // Configure player to throw synchronously on replay + val stopCallsBeforeReplay = fixture.player.stopCallCount + fixture.player.playThrows = IllegalStateException("Player init failed") + + // Replay exact cached request + fixture.controller.toggle(req) + advanceUntilIdle() + + // Must NOT escape toggle, speakingId null, onError exactly once, + // partially initialized player stopped, no resynthesis + assertNull(fixture.controller.speakingId.value) + assertEquals(1, fixture.errors.size) + assertTrue(fixture.player.stopCallCount > stopCallsBeforeReplay) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(2, fixture.player.playCallCount) + + // Replay cache is retained: if player recovers, replaying exact request does not re-synthesize + fixture.player.playThrows = null + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(3, fixture.player.playCallCount) + assertEquals(1, fixture.errors.size) + } + + @Test + fun `initial play when player throws does not escape toggle and clears speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Initial throw text") + + fixture.player.playThrows = IllegalStateException("Player failed synchronously on first play") + + // Initial noncached play throw + fixture.controller.toggle(req) + advanceUntilIdle() + + // Must NOT escape toggle, speakingId null, onError exactly once, stopped partially initialized player + assertNull(fixture.controller.speakingId.value) + assertEquals(1, fixture.errors.size) + assertTrue(fixture.player.stopCallCount >= 1) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(1, fixture.player.playCallCount) + + // Replay cache retained: if player recovers, next toggle on exact + // request uses cached audio without re-synthesizing + fixture.player.playThrows = null + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(2, fixture.player.playCallCount) + assertEquals(1, fixture.errors.size) + } + + @Test + fun `reset clears speakingId, stops player, and disposes cached audio`() = + runTest { + val fixture = Fixture(this) + val audio = FakeAudio("Reset audio") + fixture.synthesizer.synthesizeBlock = { audio } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Reset audio") + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + assertFalse(audio.isDisposed) + + fixture.controller.reset() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertTrue(audio.isDisposed) + assertTrue(fixture.player.stopCallCount >= 1) + + // Re-toggling same request after reset must re-synthesize because cache was cleared/disposed + val audio2 = FakeAudio("Reset audio 2") + fixture.synthesizer.synthesizeBlock = { audio2 } + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals(2, fixture.synthesizer.calls.size) + assertEquals("msg-1", fixture.controller.speakingId.value) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechTextTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechTextTest.kt new file mode 100644 index 00000000..c4c7d4cd --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechTextTest.kt @@ -0,0 +1,250 @@ +package com.m57.hermescontrol.ui.chat.components + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SpeechTextTest { + @Test + fun `blank input returns blank string`() { + assertEquals("", SpeechText.stripMarkdownForSpeech("")) + assertEquals("", SpeechText.stripMarkdownForSpeech(" ")) + assertEquals("", SpeechText.stripMarkdownForSpeech("\n\t\n ")) + } + + @Test + fun `plain text is preserved without changes`() { + val input = "Hello world this is a test" + val result = SpeechText.stripMarkdownForSpeech(input) + assertEquals("Hello world this is a test", result.trim()) + } + + @Test + fun `bold and italic markers are removed keeping prose`() { + val input = "This is **bold** text and *italic* text and ***bold italic*** text" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("*")) + assertTrue(result.contains("bold")) + assertTrue(result.contains("italic")) + assertTrue(result.contains("bold italic")) + } + + @Test + fun `strikethrough markers are removed keeping inner text`() { + val input = "This is ~~cancelled~~ completed" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("~")) + assertTrue(result.contains("cancelled")) + assertTrue(result.contains("completed")) + } + + @Test + fun `headers keep title text without hash characters`() { + val input = + """ + # Header 1 + ## Header 2 + ### Header 3 + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("#")) + assertTrue(result.contains("Header 1")) + assertTrue(result.contains("Header 2")) + assertTrue(result.contains("Header 3")) + } + + @Test + fun `inline code keeps code content without backticks`() { + val input = "Run `val my_value = 42` to configure" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("`")) + assertTrue(result.contains("val my_value = 42")) + assertTrue(result.contains("my_value")) + assertTrue(result.contains("to configure")) + assertFalse(result.contains(" ")) + } + + @Test + fun `fenced code block keeps code body and removes fences and language identifier`() { + val input = + """ + Here is some code: + ```kotlin + fun main() { + println("Hello") + } + ``` + Done. + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("```")) + assertFalse(result.contains("kotlin")) + assertTrue(result.contains("fun main()")) + assertTrue(result.contains("""println("Hello")""")) + assertTrue(result.contains("Here is some code:")) + assertTrue(result.contains("Done.")) + } + + @Test + fun `fenced code block body retains underscores, generics, multiplication, and pipes`() { + val input = + """ + Here is complex code: + ```typescript + const a_b_c: List = items.map(x => x * 2 | 0); + ``` + Done... + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("```")) + assertFalse(result.contains("typescript")) + assertTrue(result.contains("a_b_c")) + assertTrue(result.contains("")) + assertTrue(result.contains("* 2")) + assertTrue(result.contains("| 0")) + assertTrue(result.contains("Here is complex code:")) + assertTrue(result.contains("Done...")) + } + + @Test + fun `markdown links keep label text and strip destination url`() { + val input = "Check out [Hermes Documentation](https://hermes-agent.nousresearch.com/docs) for info" + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("Hermes Documentation")) + assertFalse(result.contains("https://hermes-agent.nousresearch.com/docs")) + assertFalse(result.contains("[") || result.contains("]")) + } + + @Test + fun `image markdown keeps alt text and strips image url`() { + val input = "Here is a diagram: ![Architecture diagram](https://example.com/arch.png)" + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("Architecture diagram")) + assertFalse(result.contains("https://example.com/arch.png")) + assertFalse(result.contains("![")) + } + + @Test + fun `bare urls are removed`() { + val input = "Visit https://example.com or http://test.org/path?arg=1 today" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("https://example.com")) + assertFalse(result.contains("http://test.org/path?arg=1")) + assertTrue(result.contains("Visit")) + assertTrue(result.contains("today")) + } + + @Test + fun `html tags are stripped keeping inner text`() { + val input = "

This is a bold paragraph with a
break.

" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("<")) + assertFalse(result.contains(">")) + assertTrue(result.contains("This is a")) + assertTrue(result.contains("bold")) + assertTrue(result.contains("paragraph with a")) + assertTrue(result.contains("break.")) + } + + @Test + fun `blockquote markers are removed keeping content`() { + val input = + """ + > This is a quote. + > It spans multiple lines. + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains(">")) + assertTrue(result.contains("This is a quote.")) + assertTrue(result.contains("It spans multiple lines.")) + } + + @Test + fun `unordered and ordered list markers are handled sensibly`() { + val input = + """ + Items: + * First item + * Second item + 1. Step one + 2. Step two + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("*")) + assertFalse(result.contains("1.")) + assertFalse(result.contains("2.")) + assertTrue(result.contains("First item")) + assertTrue(result.contains("Second item")) + assertTrue(result.contains("Step one")) + assertTrue(result.contains("Step two")) + } + + @Test + fun `arabic text is preserved intact`() { + val input = "مرحبا بك في تطبيق هيرمس! **أهلا وسهلا**" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("*")) + assertTrue(result.contains("مرحبا بك في تطبيق هيرمس!")) + assertTrue(result.contains("أهلا وسهلا")) + } + + @Test + fun `unicode and emoji are preserved`() { + val input = "Hello 👋 world! 🚀 Enjoy the day ☀️" + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("👋")) + assertTrue(result.contains("🚀")) + assertTrue(result.contains("☀️")) + assertTrue(result.contains("Hello")) + assertTrue(result.contains("world!")) + } + + @Test + fun `punctuation and code punctuation edge cases are handled without double punctuation`() { + val input = "Wait... is that it?! Yes, it is: done." + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("Wait...")) + assertTrue(result.contains("done.")) + assertFalse(result.contains("..,")) + assertFalse(result.contains(",,")) + } + + @Test + fun `newlines retain punctuation without introducing duplicate or mixed punctuation and normalize whitespace`() { + val input = + """ + Done. + Next! + مرحبا؟ + Again + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains(". .")) + assertFalse(result.contains("!.")) + assertFalse(result.contains("؟.")) + assertFalse(result.contains("\n\n")) + assertTrue(result.contains("Done.")) + assertTrue(result.contains("Next!")) + assertTrue(result.contains("مرحبا؟")) + assertTrue(result.contains("Again")) + } + + @Test + fun `horizontal rules are removed`() { + val input = + """ + Section 1 + --- + Section 2 + *** + Section 3 + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("Section 1")) + assertTrue(result.contains("Section 2")) + assertTrue(result.contains("Section 3")) + assertFalse(result.contains("---")) + assertFalse(result.contains("***")) + } +} From bbfcfada6b71058a04c3d298675db751b13164e1 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:47:59 +0300 Subject: [PATCH 031/107] feat(chat): support first-class /compress and /compact session compression (#1306) --- .../data/model/SessionCompressModels.kt | 32 ++ .../hermescontrol/data/ws/CommandModels.kt | 1 - .../m57/hermescontrol/data/ws/WsMethods.kt | 1 + .../ui/chat/ChatClarifyDelegate.kt | 24 +- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 2 + .../hermescontrol/ui/chat/ChatViewModel.kt | 114 ++++ .../ui/chat/ChatWsEventReducer.kt | 35 +- .../ui/chat/SlashCommandDispatcher.kt | 12 + .../ui/chat/fullbleed/FullBleedChatList.kt | 23 + .../ui/chat/ChatCompressRpcTest.kt | 533 ++++++++++++++++++ .../ui/chat/ChatPromptProtocolTest.kt | 22 + .../ui/chat/ChatWsEventReducerTest.kt | 127 +++++ .../ui/chat/SlashCommandDispatcherTest.kt | 9 + 13 files changed, 929 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/SessionCompressModels.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionCompressModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionCompressModels.kt new file mode 100644 index 00000000..8a9555db --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionCompressModels.kt @@ -0,0 +1,32 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class SessionCompressResponse( + val status: String? = null, + val removed: Int? = null, + val before_messages: Int? = null, + val after_messages: Int? = null, + val before_tokens: Long? = null, + val after_tokens: Long? = null, + val summary: CompressionSummary? = null, + val compressed: Boolean? = null, + val lock_held: Boolean? = null, + val message: String? = null, + val messages: List? = null, + val usage: JsonElement? = null, + val info: JsonElement? = null, +) + +@Serializable +data class CompressionSummary( + val noop: Boolean = false, + val aborted: Boolean = false, + val refused_would_grow: Boolean? = null, + val fallback_used: Boolean? = null, + val headline: String = "", + val token_line: String = "", + val note: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt index 0e0fb7c4..411cce9b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt @@ -73,7 +73,6 @@ object CommandBlocklist { "/image", "/quit", // TUI-only extras (meaningless outside the TUI) - "/compact", "/logs", "/mouse", ) 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 a6de9886..f2e07a5a 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 @@ -16,6 +16,7 @@ object WsMethods { const val SESSION_TITLE = "session.title" const val SESSION_BRANCH = "session.branch" const val SESSION_BRANCH_WHOLE = "session.branch_whole" + const val SESSION_COMPRESS = "session.compress" /** Replay recorded events newer than client's last-seen seq for a session. */ const val SESSION_EVENTS_SINCE = "session.events.since" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt index 566d7f0e..92bce3f6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt @@ -11,6 +11,21 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +internal fun buildLegacyClarifyBatchResponses( + questions: List, + answers: Map, + lockedAnswers: Map, +): List> = + questions + .asSequence() + // A replayed pending_clarify can contain answers already locked by the + // server. ClarifyBubble has no editable input for those rows, so its + // answer map carries an empty placeholder. Never send that placeholder + // back through legacy clarify.respond and overwrite accepted state. + .filterNot { it.qid in lockedAnswers } + .map { it.qid to answers[it.qid]?.trim().orEmpty() } + .toList() + class ChatClarifyDelegate( private val uiState: MutableStateFlow, private val scope: CoroutineScope, @@ -114,14 +129,17 @@ class ChatClarifyDelegate( } if (isBatch) { - for (q in questions) { - val ans = answers[q.qid]?.trim().orEmpty() + for ((questionId, ans) in buildLegacyClarifyBatchResponses( + questions = questions, + answers = answers, + lockedAnswers = lockedAnswers, + )) { val params = mutableMapOf( "session_id" to sessionId, "response" to ans, "answer" to ans, - "question_id" to q.qid, + "question_id" to questionId, ) if (clarifyId != null) { params["clarify_id"] = clarifyId 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 6dd3b8e9..a3e8dada 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 @@ -807,6 +807,8 @@ fun ChatScreen( onSaveAttachment = onSaveAttachment, savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath, openingAttachmentPath = state.openingAttachmentPath, + isCompressing = state.isCompressing && !timelineState.isHistorical, + compressionStatus = state.compressionStatus.takeUnless { timelineState.isHistorical }, onImageClick = { viewingImage = it }, speakingMessageId = speakingMessageId, onToggleSpeak = { message -> 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 ff2e727a..465e4356 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 @@ -14,6 +14,7 @@ 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 +import com.m57.hermescontrol.data.model.SessionCompressResponse import com.m57.hermescontrol.data.model.SessionTimelineEntry import com.m57.hermescontrol.data.model.UsageSnapshotResponse import com.m57.hermescontrol.data.model.parseContextBreakdown @@ -234,6 +235,8 @@ data class ChatUiState( // until the first successful session.usage fetch) — drives the // "compressed ×N" badge on the context chip. val compressionCount: Int? = null, + val isCompressing: Boolean = false, + val compressionStatus: String? = null, /** Rolling output tokens/sec over the last ~10 calls. */ val latestTps: Double? = null, /** Latest cumulative backend usage snapshot for the current session. */ @@ -3701,6 +3704,10 @@ class ChatViewModel( handleUndoCommand(result.count) } + is SlashResult.Compress -> { + compressSession(result.focusTopic) + } + is SlashResult.RpcDispatch -> { dispatchViaRpc(command) } @@ -4125,6 +4132,113 @@ class ChatViewModel( } } + private fun compressSession(focusTopic: String) { + val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId + if (sessionId == null) { + addAssistantMessage("No active session to compress.") + return + } + if (_uiState.value.isCompressing) { + return + } + + _uiState.update { + it.copy( + isCompressing = true, + compressionStatus = "⏳ Compressing context...", + ) + } + + viewModelScope.launch(ioDispatcher) { + try { + val params = + buildMap { + put("session_id", sessionId) + if (focusTopic.isNotBlank()) { + put("focus_topic", focusTopic) + } + } + // Use extended timeout (e.g. 300_000L / 5 minutes) so LLM summarization doesn't timeout + val result = + wsClient + .request( + WsMethods.SESSION_COMPRESS, + params, + timeoutMs = 300_000L, + ).await() + + handleCompressionResult(result) + } catch (e: HermesWsClient.HermesRpcException) { + addAssistantMessage("/compress: ${e.message ?: "compression failed"}") + } catch (e: Exception) { + addAssistantMessage("/compress: ${e.message ?: "compression failed"}") + } finally { + _uiState.update { + it.copy( + isCompressing = false, + compressionStatus = null, + ) + } + } + } + } + + private suspend fun handleCompressionResult(result: Any?) { + val response = + try { + val jsonElement = result.toJsonElement() + OkHttpProvider.json.decodeFromJsonElement(jsonElement) + } catch (e: Exception) { + Log.e(TAG, "Failed to decode session.compress response", e) + null + } + + val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId + + // 1. Authoritative transcript replacement + if (response?.messages != null && sessionId != null) { + val replacementMessages = + withContext(historyDispatcher) { + mapServerMessages( + sessionId = sessionId, + messages = response.messages, + offset = 0, + latestPaging = latestPaging, + liveMessages = emptyList(), + context = getApplication(), + ) + } + _uiState.update { it.copy(messages = replacementMessages) } + persistHistoryPage(replacementMessages, sessionId) + } + + // 2. Display result summary via addAssistantMessage + val summary = response?.summary + val lines = mutableListOf() + summary?.headline?.takeIf { it.isNotBlank() }?.let { lines.add(it) } + summary?.token_line?.takeIf { it.isNotBlank() }?.let { lines.add(it) } + summary?.note?.takeIf { it.isNotBlank() }?.let { lines.add(it) } + + val feedback = + if (lines.isNotEmpty()) { + lines.joinToString("\n") + } else { + response?.message ?: "Context compressed." + } + addAssistantMessage(feedback) + + // 3. Refresh usage & sessions + fetchContextUsage() + loadSessions() + + // 4. Handle lineage/session info if provided + response?.info?.let { infoElement -> + val infoMap = (infoElement.toAny() as? Map<*, *>)?.filterKeys { it is String } as? Map + handleSessionInfo(infoMap) + } + sessionHasServerPresence = true + } + private fun handlePrefillResult( message: String, notice: String, 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 f5dad2b2..1a0c983b 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 @@ -208,7 +208,7 @@ object ChatWsEventReducer { is WsEvent.SessionUpdated -> onSessionUpdated(state, streamingState) - is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState) + is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState, event) is WsEvent.ConnectionRequest, is WsEvent.ConnectionUpdate, @@ -1046,7 +1046,38 @@ object ChatWsEventReducer { private fun onStatusUpdate( state: ChatUiState, streamingState: StreamingState, - ): ReducerResult = ReducerResult(state = state, streamingState = streamingState) + event: WsEvent.StatusUpdate, + ): ReducerResult { + val kind = event.data?.get("kind") as? String + val text = event.data?.get("text") as? String + return when (kind) { + "compressing", "compacting" -> { + ReducerResult( + state = + state.copy( + isCompressing = true, + compressionStatus = text ?: "⏳ Compressing context...", + ), + streamingState = streamingState, + ) + } + + "compacted" -> { + ReducerResult( + state = + state.copy( + isCompressing = false, + compressionStatus = text, + ), + streamingState = streamingState, + ) + } + + else -> { + ReducerResult(state = state, streamingState = streamingState) + } + } + } private fun onUnknown( state: ChatUiState, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt index b1e49b0a..c1aba996 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -77,6 +77,11 @@ class SlashCommandDispatcher { SlashResult.Undo(count = arg) } + "/compress", "/compact" -> { + val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() + SlashResult.Compress(focusTopic = arg) + } + else -> { SlashResult.RpcDispatch } @@ -172,4 +177,11 @@ sealed class SlashResult { data class Undo( val count: String, ) : SlashResult() + + /** + * Compress session context via the session.compress WebSocket RPC. + */ + data class Compress( + val focusTopic: String, + ) : SlashResult() } 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 6d0bb55b..0deaff27 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 @@ -3,11 +3,15 @@ package com.m57.hermescontrol.ui.chat.fullbleed import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -107,6 +111,8 @@ fun FullBleedChatList( replyErrorContent: (@Composable () -> Unit)? = null, speakingMessageId: String? = null, onToggleSpeak: ((ChatMessage) -> Unit)? = null, + isCompressing: Boolean = false, + compressionStatus: String? = null, ) { if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) { Box( @@ -149,6 +155,23 @@ fun FullBleedChatList( val tailItems = buildMap Unit> { replyErrorContent?.let { put("reply_error", it) } + if (isCompressing || compressionStatus != null) { + put("compression_status") { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = compressionStatus ?: "⏳ Compressing context...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } agentStatus?.let { status -> put("agent_status") { AgentStatusIndicator(status = status) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt new file mode 100644 index 00000000..daed040b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt @@ -0,0 +1,533 @@ +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.CompressionSummary +import com.m57.hermescontrol.data.model.SessionCompressResponse +import com.m57.hermescontrol.data.model.SessionMessage +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator +import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.data.ws.HermesWsClient +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 +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatCompressRpcTest { + private val testDispatcher = StandardTestDispatcher() + private val mockEventsFlow = MutableSharedFlow(extraBufferCapacity = 64) + private val mockConnectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED) + private lateinit var app: Application + private lateinit var fakeRepo: FakeChatPersistenceRepository + private var reqCount = 0 + private var sessionCreateRequestId: String? = null + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + reqCount = 0 + sessionCreateRequestId = null + + mockkStatic(android.util.Log::class) + every { android.util.Log.d(any(), any()) } returns 0 + every { android.util.Log.i(any(), any()) } returns 0 + every { android.util.Log.w(any(), any()) } returns 0 + every { android.util.Log.e(any(), any(), any()) } returns 0 + + mockkObject(AuthManager) + every { AuthManager.getPinnedModels() } returns emptyList() + every { AuthManager.dataScopeFlow } returns MutableStateFlow(null) + every { AuthManager.activeProfileId } returns MutableStateFlow(null) + every { AuthManager.currentDataScope() } returns null + every { AuthManager.getToken() } returns "test-token" + 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 + every { AuthManager.isUserMessageTokensEnabled() } returns true + every { AuthManager.isAssistantMessageTokensEnabled() } returns true + every { AuthManager.isTokensPerSecondEnabled() } returns true + every { AuthManager.isModelProviderShown() } returns false + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.isRestoreLastSession() } returns false + every { AuthManager.getLastOpenedSessionId() } returns null + every { AuthManager.setLastOpenedSessionId(any()) } returns Unit + every { AuthManager.clearLastOpenedSessionId() } returns Unit + + mockkObject(HermesWsClient) + every { HermesWsClient.events } returns mockEventsFlow + every { HermesWsClient.connectionStatus } returns mockConnectionStatus + every { HermesWsClient.connect() } answers { + mockConnectionStatus.value = ConnectionStatus.CONNECTING + } + every { HermesWsClient.disconnect() } returns Unit + + every { HermesWsClient.send(any(), any(), any()) } answers { + reqCount++ + val id = "req-id-$reqCount" + if (arg(0) == WsMethods.SESSION_CREATE) { + sessionCreateRequestId = id + } + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + reqCount++ + val id = "req-msg-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + every { HermesWsClient.request(any(), any(), any()) } answers { + HermesWsClient.send(arg(0), arg(1)) {} + CompletableDeferred(Unit) + } + + mockkObject(ApiClient) + coEvery { ApiClient.hermesApi.getModelInfo() } returns retrofit2.Response.success(mockk(relaxed = true)) + coEvery { ApiClient.hermesApi.getProfiles() } returns + retrofit2.Response.success( + com.m57.hermescontrol.data.model + .ProfilesResponse(emptyList()), + ) + + mockkObject(HermesDatabase) + mockkObject(ProfileSwitchCoordinator) + every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow() + every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow() + + app = mockk(relaxed = true) + fakeRepo = FakeChatPersistenceRepository() + mockConnectionStatus.value = ConnectionStatus.DISCONNECTED + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + private suspend fun TestScope.createViewModelWithSession(): Pair { + val vm = + ChatViewModel( + app, + startCleanup = false, + repo = fakeRepo, + slashUsageStore = FakeSlashUsageStore(), + searchDispatcher = testDispatcher, + ioDispatcher = testDispatcher, + historyDispatcher = testDispatcher, + sendStore = ChatSendStore(), + ) + advanceUntilIdle() + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + val createRequestId = requireNotNull(sessionCreateRequestId) { "session.create was not sent" } + mockEventsFlow.emit(WsEvent.RpcResult(createRequestId, mapOf("session_id" to "session-xyz"))) + advanceUntilIdle() + return Pair(vm, "session-xyz") + } + + @Test + fun `compress calls SESSION_COMPRESS not command dispatch or slash exec`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val methodCalls = mutableListOf() + every { + HermesWsClient.request(capture(methodCalls), any(), any()) + } answers { + CompletableDeferred(null) + } + + vm.sendMessage("/compress") + advanceUntilIdle() + + assertTrue("expected SESSION_COMPRESS in $methodCalls", WsMethods.SESSION_COMPRESS in methodCalls) + assertTrue("expected NO command.dispatch", WsMethods.COMMAND_DISPATCH !in methodCalls) + assertTrue("expected NO slash.exec", "slash.exec" !in methodCalls) + } + + @Test + fun `compress request parameters contain session_id and omit focus_topic when blank`() = + runTest { + val (vm, sessionId) = createViewModelWithSession() + + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + CompletableDeferred(null) + } + + vm.sendMessage("/compress") + advanceUntilIdle() + + val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) + assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) + val params = paramsCalls[compressIndex] + assertEquals(sessionId, params["session_id"]) + assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic")) + } + + @Test + fun `compress request parameters forward focus_topic when non-blank`() = + runTest { + val (vm, sessionId) = createViewModelWithSession() + + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + CompletableDeferred(null) + } + + vm.sendMessage("/compress auth decisions") + advanceUntilIdle() + + val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) + assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) + val params = paramsCalls[compressIndex] + assertEquals(sessionId, params["session_id"]) + assertEquals("auth decisions", params["focus_topic"]) + } + + @Test + fun `compact behaves identically to compress omitting focus_topic when blank`() = + runTest { + val (vm, sessionId) = createViewModelWithSession() + + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + CompletableDeferred(null) + } + + vm.sendMessage("/compact") + advanceUntilIdle() + + val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) + assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) + val params = paramsCalls[compressIndex] + assertEquals(sessionId, params["session_id"]) + assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic")) + assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls) + } + + @Test + fun `compact forwards focus_topic when non-blank`() = + runTest { + val (vm, sessionId) = createViewModelWithSession() + + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + CompletableDeferred(null) + } + + vm.sendMessage("/compact architecture details") + advanceUntilIdle() + + val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) + assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) + val params = paramsCalls[compressIndex] + assertEquals(sessionId, params["session_id"]) + assertEquals("architecture details", params["focus_topic"]) + } + + @Test + fun `final response summary headline token_line note added to messages`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val responseJson = + buildJsonObject { + put("status", JsonPrimitive("ok")) + put( + "summary", + buildJsonObject { + put("headline", JsonPrimitive("Context compressed")) + put("token_line", JsonPrimitive("Tokens: 120k -> 35k (70% saved)")) + put("note", JsonPrimitive("Trimmed 14 earlier turns")) + }, + ) + } + + every { + HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any()) + } answers { + CompletableDeferred(responseJson) + } + + vm.sendMessage("/compress") + advanceUntilIdle() + + val lastAssistantMessage = + vm.uiState.value.messages + .lastOrNull { it.role == MessageRole.ASSISTANT } + val expectedContent = "Context compressed\nTokens: 120k -> 35k (70% saved)\nTrimmed 14 earlier turns" + assertEquals(expectedContent, lastAssistantMessage?.content) + } + + @Test + fun `returned messages replace and reconcile visible live transcript`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val serverMessages = + listOf( + SessionMessage( + id = 101, + role = "user", + content = JsonPrimitive("Original prompt"), + ), + SessionMessage( + id = 102, + role = "assistant", + content = JsonPrimitive("Compressed response"), + ), + ) + + val responseJson = + buildJsonObject { + put("status", JsonPrimitive("ok")) + put( + "summary", + buildJsonObject { + put("headline", JsonPrimitive("Summary headline")) + }, + ) + put( + "messages", + buildJsonArray { + add( + buildJsonObject { + put("id", JsonPrimitive(101)) + put("role", JsonPrimitive("user")) + put("content", JsonPrimitive("Original prompt")) + }, + ) + add( + buildJsonObject { + put("id", JsonPrimitive(102)) + put("role", JsonPrimitive("assistant")) + put("content", JsonPrimitive("Compressed response")) + }, + ) + }, + ) + } + + every { + HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any()) + } answers { + CompletableDeferred(responseJson) + } + + vm.sendMessage("/compress") + advanceUntilIdle() + + val messages = vm.uiState.value.messages + // The transcript contains serverMessages, plus the assistant summary feedback + val userMsg = messages.firstOrNull { it.role == MessageRole.USER && it.content == "Original prompt" } + assertEquals("Original prompt", userMsg?.content) + val serverAssistantMsg = + messages.firstOrNull { + it.role == MessageRole.ASSISTANT && it.content == "Compressed response" + } + assertTrue("expected replacement assistant message", serverAssistantMsg != null) + val summaryMsg = messages.lastOrNull { it.role == MessageRole.ASSISTANT } + assertEquals("Summary headline", summaryMsg?.content) + } + + @Test + fun `fetchContextUsage is triggered after successful compression`() = + runTest { + val (vm, sessionId) = createViewModelWithSession() + + val methodCalls = mutableListOf() + every { + HermesWsClient.request(capture(methodCalls), any(), any()) + } answers { + val method = arg(0) + if (method == WsMethods.SESSION_COMPRESS) { + val responseJson = + buildJsonObject { + put("status", JsonPrimitive("ok")) + put("message", JsonPrimitive("Context compressed.")) + } + CompletableDeferred(responseJson) + } else { + CompletableDeferred(Unit) + } + } + + methodCalls.clear() + + vm.sendMessage("/compress") + advanceUntilIdle() + + // fetchContextUsage calls SESSION_CONTEXT_BREAKDOWN and SESSION_USAGE with sessionId + assertTrue( + "expected SESSION_CONTEXT_BREAKDOWN in $methodCalls", + WsMethods.SESSION_CONTEXT_BREAKDOWN in methodCalls, + ) + assertTrue( + "expected SESSION_USAGE in $methodCalls", + WsMethods.SESSION_USAGE in methodCalls, + ) + } + + @Test + fun `isCompressing is reset to false on success`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val deferred = CompletableDeferred() + every { + HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any()) + } returns deferred + + vm.sendMessage("/compress") + // Progress before completion + testDispatcher.scheduler.runCurrent() + assertTrue("isCompressing should be true while in flight", vm.uiState.value.isCompressing) + assertEquals("⏳ Compressing context...", vm.uiState.value.compressionStatus) + + deferred.complete( + buildJsonObject { + put("status", JsonPrimitive("ok")) + }, + ) + advanceUntilIdle() + + assertFalse("isCompressing should be reset to false", vm.uiState.value.isCompressing) + assertEquals(null, vm.uiState.value.compressionStatus) + } + + @Test + fun `isCompressing is reset to false on RPC error`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val deferred = CompletableDeferred() + every { + HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any()) + } returns deferred + + vm.sendMessage("/compress") + testDispatcher.scheduler.runCurrent() + assertTrue(vm.uiState.value.isCompressing) + + deferred.completeExceptionally( + HermesWsClient.HermesRpcException("compression failed on backend", 4018), + ) + advanceUntilIdle() + + assertFalse("isCompressing should be false on RPC error", vm.uiState.value.isCompressing) + assertEquals(null, vm.uiState.value.compressionStatus) + val errorMsg = + vm.uiState.value.messages + .lastOrNull { it.role == MessageRole.ASSISTANT } + assertTrue(errorMsg?.content?.contains("compression failed on backend") == true) + } + + @Test + fun `isCompressing is reset to false on generic exception`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val deferred = CompletableDeferred() + every { + HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any()) + } returns deferred + + vm.sendMessage("/compress") + testDispatcher.scheduler.runCurrent() + assertTrue(vm.uiState.value.isCompressing) + + deferred.completeExceptionally(RuntimeException("network disconnected")) + advanceUntilIdle() + + assertFalse("isCompressing should be false on generic exception", vm.uiState.value.isCompressing) + assertEquals(null, vm.uiState.value.compressionStatus) + val errorMsg = + vm.uiState.value.messages + .lastOrNull { it.role == MessageRole.ASSISTANT } + assertTrue(errorMsg?.content?.contains("network disconnected") == true) + } + + @Test + fun `duplicate compress while isCompressing is true is a no-op`() = + runTest { + val (vm, _) = createViewModelWithSession() + + var compressCalls = 0 + val deferred = CompletableDeferred() + every { + HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any()) + } answers { + compressCalls++ + deferred + } + + vm.sendMessage("/compress") + testDispatcher.scheduler.runCurrent() + assertTrue(vm.uiState.value.isCompressing) + assertEquals(1, compressCalls) + + // Second invocation while in flight + vm.sendMessage("/compress") + testDispatcher.scheduler.runCurrent() + assertEquals("duplicate call should not trigger another RPC", 1, compressCalls) + + // Also check /compact is blocked while in flight + vm.sendMessage("/compact") + testDispatcher.scheduler.runCurrent() + assertEquals("compact while compressing should not trigger another RPC", 1, compressCalls) + + deferred.complete(null) + advanceUntilIdle() + assertFalse(vm.uiState.value.isCompressing) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt index 55f61e9d..412c20f7 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt @@ -64,6 +64,28 @@ class ChatPromptProtocolTest { assertEquals("new answer", answers?.get("q1")?.jsonPrimitive?.content) } + @Test + fun legacyClarifyBatchReplay_skipsLockedAnswers_withoutEmptyOverwrite() { + val responses = + buildLegacyClarifyBatchResponses( + questions = + listOf( + ClarifyQuestionUi(qid = "q0", question = "Already answered?"), + ClarifyQuestionUi(qid = "q1", question = "Still waiting?"), + ClarifyQuestionUi(qid = "q2", question = "Optional skip?"), + ), + answers = + mapOf( + "q0" to "", + "q1" to " new answer ", + "q2" to "", + ), + lockedAnswers = mapOf("q0" to "yes"), + ) + + assertEquals(listOf("q1" to "new answer", "q2" to ""), responses) + } + @Test fun credentialPrompt_serverRequestId_controls_response_and_cancellation() { val scope = TestScope(dispatcher) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index 7b5814ae..6d3dc887 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -1478,4 +1478,131 @@ class ChatWsEventReducerTest { assertTrue(result.state.messages.none { it.id != "orphan" }) assertTrue(!result.streamingState.turnUsageBaselineCaptured) } + + @Test + fun testStatusUpdate_compressingState() { + val initialMessage = ChatMessage(role = MessageRole.USER, content = "hello") + val state = + ChatUiState( + currentSessionId = "session-1", + messages = listOf(initialMessage), + isCompressing = false, + compressionStatus = null, + ) + val event = + WsEvent.StatusUpdate( + status = null, + data = + mapOf( + "kind" to "compressing", + "text" to "⏳ Compressing context...", + ), + ) + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + + assertTrue(result.state.isCompressing) + assertEquals("⏳ Compressing context...", result.state.compressionStatus) + assertEquals(listOf(initialMessage), result.state.messages) + } + + @Test + fun testStatusUpdate_compactingState() { + val state = + ChatUiState( + currentSessionId = "session-1", + isCompressing = false, + compressionStatus = null, + ) + val event = + WsEvent.StatusUpdate( + status = null, + data = + mapOf( + "kind" to "compacting", + "text" to "🗜️ Compacting...", + ), + ) + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + + assertTrue(result.state.isCompressing) + assertEquals("🗜️ Compacting...", result.state.compressionStatus) + } + + @Test + fun testStatusUpdate_compactedState() { + val state = + ChatUiState( + currentSessionId = "session-1", + isCompressing = true, + compressionStatus = "🗜️ Compacting...", + ) + val event = + WsEvent.StatusUpdate( + status = null, + data = + mapOf( + "kind" to "compacted", + "text" to "Finished", + ), + ) + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + + assertFalse(result.state.isCompressing) + // Accepts either "Finished" or null if cleared + assertTrue(result.state.compressionStatus == "Finished" || result.state.compressionStatus == null) + } + + @Test + fun testStatusUpdate_unrelatedKindIgnored() { + val initialMessages = listOf(ChatMessage(role = MessageRole.USER, content = "test")) + val state = + ChatUiState( + currentSessionId = "session-1", + messages = initialMessages, + isCompressing = false, + compressionStatus = "idle", + ) + val event = + WsEvent.StatusUpdate( + status = null, + data = + mapOf( + "kind" to "other", + "text" to "Something else", + ), + ) + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + + assertFalse(result.state.isCompressing) + assertEquals("idle", result.state.compressionStatus) + assertEquals(initialMessages, result.state.messages) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt index b85b4f23..272ad6ea 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt @@ -98,6 +98,15 @@ class SlashCommandDispatcherTest { assertEquals(SlashResult.Undo(""), dispatcher.dispatch("/Undo")) } + @Test + fun `compress and compact route to Compress`() { + assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compress")) + assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compact")) + assertEquals(SlashResult.Compress("auth decisions"), dispatcher.dispatch("/compress auth decisions")) + assertEquals(SlashResult.Compress("auth decisions"), dispatcher.dispatch("/compact auth decisions")) + assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compress ")) + } + @Test fun `NEW uppercase still routes to NewSession`() { // Dispatcher lower-cases before matching, so case must not matter. From dc4f2b0794eab8ab6ec5ae276e7b383508568450 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:23:59 +0300 Subject: [PATCH 032/107] revert(chat): remove busy-send panel and composer queue button (#1308) --- .../hermescontrol/ui/chat/ChatScreenTest.kt | 35 ---- .../components/ComposerInteractionTest.kt | 153 ------------------ .../chat/components/PendingSendPanelTest.kt | 98 ----------- .../ui/chat/ChatQueuePresentation.kt | 17 -- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 20 +-- .../ui/chat/components/ChatComposer.kt | 8 - .../ui/chat/components/ComposerToolbar.kt | 40 +---- .../ui/chat/components/PendingSendPanel.kt | 103 ------------ .../ui/chat/ChatQueuePresentationTest.kt | 48 ------ 9 files changed, 2 insertions(+), 520 deletions(-) delete mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt delete mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.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 5e5c5b79..52dd188f 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 @@ -10,13 +10,11 @@ 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 @@ -128,37 +126,4 @@ 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 63317410..cdda65ee 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 @@ -32,7 +32,6 @@ 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 @@ -58,158 +57,6 @@ 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", 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 deleted file mode 100644 index ab69dd29..00000000 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -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/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt deleted file mode 100644 index c95b505d..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt +++ /dev/null @@ -1,17 +0,0 @@ -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 a3e8dada..1568e8dd 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 @@ -87,7 +87,6 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.local.AuthManager 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.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.ConnectionStatus @@ -178,11 +177,7 @@ 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 sourceMessages = timelineState.historyMessages ?: state.messages - val displayedMessages = - remember(sourceMessages, state.pendingSends) { - messagesWithoutUnsentQueue(sourceMessages, state.pendingSends) - } + val displayedMessages = timelineState.historyMessages ?: state.messages val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current var browserAuthInFlight by rememberSaveable { mutableStateOf(false) } @@ -904,12 +899,6 @@ fun ChatScreen( }, ) - com.m57.hermescontrol.ui.chat.components.PendingSendPanel( - sends = state.pendingSends, - mainTurnBusy = state.isMainTurnBusy, - onSendNow = viewModel::sendQueuedNow, - ) - ChatInputBar( inputFieldValue = inputFieldValue, onInputChange = { inputFieldValue = it }, @@ -920,12 +909,6 @@ fun ChatScreen( scrollController.jumpToBottom(animated = true) } }, - onBusySend = { mode -> - if (viewModel.sendMessage(inputFieldValue.text, mode)) { - inputFieldValue = TextFieldValue("") - scrollController.jumpToBottom(animated = true) - } - }, onMicTap = mediaLaunchers.onMicTap, onMicHoldStart = mediaLaunchers.onMicHoldStart, onMicHoldEnd = mediaLaunchers.onMicHoldEnd, @@ -936,7 +919,6 @@ fun ChatScreen( isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked, onStopGeneration = { viewModel.interruptSession() }, isAgentTyping = state.isAgentTyping, - isMainTurnBusy = state.isMainTurnBusy, canInterrupt = state.canInterrupt, isConnected = state.isConnected, isSessionReady = state.isSessionReady && !timelineState.isHistorical, 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 79638974..2a49d067 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 @@ -73,7 +73,6 @@ 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 @@ -90,11 +89,9 @@ fun ChatInputBar( inputFieldValue: TextFieldValue, onInputChange: (TextFieldValue) -> Unit, onSend: () -> Unit, - onBusySend: (BusySendMode) -> Unit = {}, onMicTap: () -> Unit, isListening: Boolean, isAgentTyping: Boolean, - isMainTurnBusy: Boolean = false, canInterrupt: Boolean = false, isConnected: Boolean, commandCatalog: CommandCatalog, @@ -137,8 +134,6 @@ fun ChatInputBar( 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) } @@ -429,10 +424,7 @@ fun ChatInputBar( isListening = isListening, canSend = canSend, showSend = hasDraft, - showQueue = showBusyActions, onSend = onSend, - onQueue = { onBusySend(BusySendMode.QUEUE) }, - onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) }, canInterrupt = canInterrupt, onStopGeneration = onStopGeneration, onAttachTap = { showAttachmentTray = !showAttachmentTray }, 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 a85c3699..6b47a49c 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 @@ -31,7 +31,6 @@ 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 @@ -69,9 +68,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback 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 @@ -113,10 +110,7 @@ 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, @@ -562,27 +556,7 @@ 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 - val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send) // While a recording is locked this button finishes the voice note, so // it must stay enabled even when the draft alone would not send // (review, PR #1280). @@ -605,17 +579,7 @@ fun ComposerToolbar( } else { palette.action }, - ).semantics { - if (showSend && showQueue && canSend && !isVoiceNoteLocked) { - customActions = - listOf( - CustomAccessibilityAction(stopAndSendLabel) { - onStopAndSend() - true - }, - ) - } - }.combinedClickable( + ).combinedClickable( enabled = actionEnabled, onClick = { when { @@ -631,8 +595,6 @@ fun ComposerToolbar( else -> onMicTap() } }, - onLongClick = if (showSend && showQueue && !isVoiceNoteLocked) onStopAndSend else null, - onLongClickLabel = if (showSend && showQueue && !isVoiceNoteLocked) stopAndSendLabel else null, ).testTag( when { isVoiceNoteLocked -> "voice_note_send_button" 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 deleted file mode 100644 index 9147e2fe..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt +++ /dev/null @@ -1,103 +0,0 @@ -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/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt deleted file mode 100644 index e11e1dc4..00000000 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -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)))) - } -} From 2a41a8a4171af548f6732011604d74d9705d5896 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:31:18 +0300 Subject: [PATCH 033/107] fix(nix): share Gradle home so worktree daemons stop piling up (#1339) --- flake.nix | 8 +++----- gradle.properties | 2 ++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flake.nix b/flake.nix index 88a42a3a..6a57fb96 100644 --- a/flake.nix +++ b/flake.nix @@ -76,8 +76,9 @@ ANDROID_SDK_ROOT = "${androidSdk}/libexec/android-sdk"; JAVA_HOME = "${pkgs.jdk21}"; - # Gradle needs a writable home - GRADLE_USER_HOME = "$PWD/.gradle-home"; + # GRADLE_USER_HOME stays at the default ~/.gradle: Nix attrs are not + # shell-expanded, so "$PWD/..." gave every checkout and worktree its + # own cache and daemon pool, and idle daemons piled up in RAM. shellHook = '' echo " HermesControl Android dev shell" @@ -90,9 +91,6 @@ # Ensure Android CLI tools and local user binaries are on PATH export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$HOME/.local/bin:$PATH" - # Writable gradle home - mkdir -p "$GRADLE_USER_HOME" - # Enable host keyboard input for the project's existing AVD. hermesAvdConfig="''${ANDROID_AVD_HOME:-''${ANDROID_USER_HOME:-$HOME/.android}/avd}/hermes_dev.avd/config.ini" if [ -f "$hermesAvdConfig" ]; then diff --git a/gradle.properties b/gradle.properties index 32d72a93..76cd1e6a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,8 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# Stop idle daemons after 20 min (default 3 h) so finished builds release their RAM. +org.gradle.daemon.idletimeout=1200000 # Enables Gradle Build Cache. # See https://docs.gradle.org/current/userguide/build_cache.html org.gradle.caching=true From d81fc39f2503ee5e2664c22bfd88bf9aaf5fa593 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:20:52 +0300 Subject: [PATCH 034/107] ci: faster, stricter CI (instrumented gate, parallel jobs, test forks) (#1348) --- .github/workflows/android.yml | 37 ++++++++++++++----- app/build.gradle.kts | 9 ++++- .../hermescontrol/data/ws/HermesWsClient.kt | 3 +- .../notification/NotificationReplyReceiver.kt | 8 +++- .../data/ws/HermesWsClientTest.kt | 3 +- .../NotificationReplyReceiverTest.kt | 17 ++++++--- 6 files changed, 58 insertions(+), 19 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 0697eb85..4783362d 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -97,11 +97,8 @@ jobs: - name: Add local bin to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH - - name: Run ktlintCheck - run: ./gradlew ktlintCheck - - - name: Run checkColorLiterals (hardcoded Color guard, issue #622) - run: ./gradlew checkColorLiterals + - name: Run ktlintCheck + checkColorLiterals (hardcoded Color guard, issue #622) + run: ./gradlew ktlintCheck checkColorLiterals --continue - name: Upload ktlint reports if: always() @@ -185,6 +182,25 @@ jobs: - name: Run test run: ./gradlew test + # Telemetry only, never a gate: surfaces the slowest classes so perf work + # targets real offenders. + - name: Slowest unit-test classes + if: always() + run: | + python3 - <<'EOF' >> "$GITHUB_STEP_SUMMARY" + import glob, xml.etree.ElementTree as ET + rows = [] + for path in glob.glob("app/build/test-results/testDebugUnitTest/*.xml"): + root = ET.parse(path).getroot() + rows.append((float(root.get("time", 0)), int(root.get("tests", 0)), root.get("name", path))) + rows.sort(reverse=True) + print("### Slowest unit-test classes\n") + print(f"{len(rows)} classes, {sum(r[0] for r in rows):.0f}s summed across forks\n") + print("| Time | Tests | Class |\n|---:|---:|---|") + for secs, count, name in rows[:20]: + print(f"| {secs:.1f}s | {count} | `{name.rsplit('.', 1)[-1]}` |") + EOF + - name: Upload test reports if: always() uses: actions/upload-artifact@v7 @@ -194,11 +210,11 @@ jobs: retention-days: 7 # ───────────────────────────────────────────────────────────────────── - # Job 4: Build Debug APK — depends on all three above passing. + # Job 4: Build Debug APK — runs in parallel; CI Summary gates the result. # ───────────────────────────────────────────────────────────────────── build: name: Build Debug APK - needs: [changes, ktlint, android-lint, unit-tests] + needs: [changes] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -241,7 +257,7 @@ jobs: # ───────────────────────────────────────────────────────────────────── release-compile: name: Release Compile Check - needs: [changes, ktlint, android-lint, unit-tests] + needs: [changes] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -339,7 +355,7 @@ jobs: # ───────────────────────────────────────────────────────────────────── ci-summary: name: CI Summary - needs: [changes, ktlint, android-lint, unit-tests, build, release-compile] + needs: [changes, ktlint, android-lint, unit-tests, build, release-compile, instrumented-tests] runs-on: ubuntu-latest timeout-minutes: 2 if: always() @@ -355,11 +371,12 @@ jobs: TEST_RESULT: ${{ needs.unit-tests.result }} BUILD_RESULT: ${{ needs.build.result }} RELEASE_COMPILE_RESULT: ${{ needs.release-compile.result }} + INSTRUMENTED_RESULT: ${{ needs.instrumented-tests.result }} run: | LABEL="ci-red" FAILED=0 FAILED_JOBS="" - for entry in "ktlint:$KTLINT_RESULT" "android-lint:$LINT_RESULT" "unit-tests:$TEST_RESULT" "build:$BUILD_RESULT" "release-compile:$RELEASE_COMPILE_RESULT"; do + for entry in "ktlint:$KTLINT_RESULT" "android-lint:$LINT_RESULT" "unit-tests:$TEST_RESULT" "build:$BUILD_RESULT" "release-compile:$RELEASE_COMPILE_RESULT" "instrumented-tests:$INSTRUMENTED_RESULT"; do name="${entry%%:*}" result="${entry#*:}" echo "::notice::$name = $result" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index df81b98c..a28b5da5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -309,6 +309,13 @@ tasks.named("check") { dependsOn("checkColorLiterals") } -tasks.withType { +val includeBenchmarks = providers.gradleProperty("includeBenchmarks").isPresent + +tasks.withType().configureEach { useJUnitPlatform() + maxParallelForks = 2 + // Assertion-free timing probes are opt-in: ./gradlew testDebugUnitTest -PincludeBenchmarks + if (!includeBenchmarks) { + filter { excludeTestsMatching("*Benchmark") } + } } 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 1098e7be..09f3166a 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 @@ -415,6 +415,7 @@ object HermesWsClient { @VisibleForTesting internal fun probeLivenessOnTransportChange( + timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS, onFailureAction: () -> Unit = { synchronized(outboundLock) { if (connected.get()) webSocket?.cancel() @@ -426,7 +427,7 @@ object HermesWsClient { transportProbeJob = wsScope.launch { Log.d(TAG, "Network transport changed — probing WebSocket liveness") - val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess + val alive = runCatching { ping(timeoutMs) }.isSuccess if (alive) { Log.d(TAG, "WebSocket liveness probe succeeded on new transport") return@launch diff --git a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt index b776b055..42867446 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt @@ -25,6 +25,9 @@ open class NotificationReplyReceiver : BroadcastReceiver() { const val EXTRA_SESSION_ID = "extra_session_id" private const val REPLY_TIMEOUT_MS = 5_000L + /** Overall deadline for persisting and submitting one notification reply. */ + private const val PERSISTENCE_TIMEOUT_MS = 5_000L + /** * Turn-boundary read budget inside the reply deadline. Deliberately tight: * the probe sits on the reply path, so a slow or unreachable gateway must @@ -44,6 +47,9 @@ open class NotificationReplyReceiver : BroadcastReceiver() { */ internal open fun goAsyncCompat(): BroadcastReceiver.PendingResult = goAsync() + /** Overridable so tests can prove the timeout path without waiting 5s. */ + internal open val persistenceTimeoutMs: Long get() = PERSISTENCE_TIMEOUT_MS + /** * Test-friendly wrapper for notification creation. Override in tests to * avoid [NotificationCompat.Builder.build()] calling Android framework @@ -75,7 +81,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() { val pendingResult = goAsyncCompat() replyScope.launch { try { - withTimeout(5000L) { + withTimeout(persistenceTimeoutMs) { withContext(Dispatchers.IO) { val db = com.m57.hermescontrol.data.local.HermesDatabase diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt index e3df3221..14d5800e 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt @@ -2188,12 +2188,13 @@ class HermesWsClientTest { val failureLatch = CountDownLatch(1) HermesWsClient.probeLivenessOnTransportChange( + timeoutMs = 50L, onFailureAction = { failureLatch.countDown() }, ) - assertTrue("Expected failure action to be invoked on failed ping", failureLatch.await(6, TimeUnit.SECONDS)) + assertTrue("Expected failure action to be invoked on failed ping", failureLatch.await(3, TimeUnit.SECONDS)) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt index c287e05f..fd0953b9 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt @@ -412,16 +412,23 @@ class NotificationReplyReceiverTest { kotlinx.coroutines.delay(10000) } - receiver.onReceive(mockContext, mockIntent) + // Same timeout path as production, with a short deadline instead of 5s. + val shortTimeoutReceiver = + object : NotificationReplyReceiver() { + override fun goAsyncCompat(): BroadcastReceiver.PendingResult = mockPendingResult - // Wait longer than the 5-second timeout, but less than the 10-second delay - Thread.sleep(6000) + override fun buildReplyNotification(context: Context): Notification = mockNotification + + override val persistenceTimeoutMs: Long = 50L + } + + shortTimeoutReceiver.onReceive(mockContext, mockIntent) // Timeout should be caught in catch block and logged - verify { android.util.Log.e("NotificationReply", any(), any()) } + verify(timeout = 3_000) { android.util.Log.e("NotificationReply", any(), any()) } // Pending result must still finish after timeout - verify { mockPendingResult.finish() } + verify(timeout = 3_000) { mockPendingResult.finish() } } // ── Helpers ──────────────────────────────────────────────────────────── From 2bf51cb911c23cdf46afbc38ebc191c094e39fb9 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:32:07 +0300 Subject: [PATCH 035/107] fix(chat): stop prefix dedupe merging distinct messages (#1335) (#1340) --- .../ui/chat/ChatTranscriptMerge.kt | 3 + .../ui/chat/ChatPrefixDedupeTest.kt | 162 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPrefixDedupeTest.kt 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 4d0214f1..afe8769e 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 @@ -201,6 +201,9 @@ internal class TranscriptComparison( ) { return true } + // #842 / #1335: prefix drift only exists for sealed assistant narration; two REST + // rows already returned above, so user rows must never collapse by prefix. + if (a.role != MessageRole.ASSISTANT) return false return ta.length >= 40 && tb.length >= 40 && (tb.startsWith(ta) || ta.startsWith(tb)) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPrefixDedupeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPrefixDedupeTest.kt new file mode 100644 index 00000000..9aff13c2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPrefixDedupeTest.kt @@ -0,0 +1,162 @@ +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 + +/** + * Issue #1335: prefix dedupe must not merge distinct messages. + */ +class ChatPrefixDedupeTest { + // ── Contract 1 ──────────────────────────────────────────────────────────── + // Two USER messages, both >=40 chars, one a strict prefix of the other, + // different ids -> NOT the same logical message (both survive a merge). + + @Test + fun contract1_userMessages_strictPrefixDistinctIds_notSameLogicalMessage() { + val prefixText = "Please write a comprehensive test suite for issue thirteen thirty five." + val fullText = "$prefixText It should cover all edge cases thoroughly." + val msg1 = ChatMessage(id = "user-msg-1", role = MessageRole.USER, content = prefixText) + val msg2 = ChatMessage(id = "user-msg-2", role = MessageRole.USER, content = fullText) + + assertFalse(sameLogicalMessage(msg1, msg2)) + assertFalse(sameLogicalMessage(msg2, msg1)) + + val merged = mergeTranscriptWithLive(listOf(msg2), listOf(msg1), preserveLiveIds = true) + assertEquals(2, merged.size) + assertTrue(merged.any { it.id == "user-msg-1" && it.content == prefixText }) + assertTrue(merged.any { it.id == "user-msg-2" && it.content == fullText }) + } + + // ── Contract 2 ──────────────────────────────────────────────────────────── + // Two ASSISTANT messages that are both canonical REST rows (distinct restIds / + // canonical ids), one a >=40-char prefix of the other -> NOT same. + + @Test + fun contract2_assistantCanonicalRestRows_strictPrefix_notSameLogicalMessage() { + val prefixText = "I have inspected the project repository and verified the build files." + val fullText = "$prefixText Now proceeding with the implementation of the requested tests." + val restRow1 = + ChatMessage( + id = "rest-s-101", + role = MessageRole.ASSISTANT, + content = prefixText, + restId = "rest-s-101", + ) + val restRow2 = + ChatMessage( + id = "rest-s-102", + role = MessageRole.ASSISTANT, + content = fullText, + restId = "rest-s-102", + ) + + assertFalse(sameLogicalMessage(restRow1, restRow2)) + assertFalse(sameLogicalMessage(restRow2, restRow1)) + + val merged = mergeTranscriptWithLive(listOf(restRow2), listOf(restRow1), preserveLiveIds = true) + assertEquals(2, merged.size) + } + + // ── Contract 3 ──────────────────────────────────────────────────────────── + // TOOL rows never match by content prefix. + + @Test + fun contract3_toolRows_neverMatchByContentPrefix() { + val prefixToolContent = """{"output":"build succeeded without any compiler errors or warnings during check"}""" + val fullToolContent = + """{"output":"build succeeded without any compiler errors or warnings during check and all tests pass"}""" + val tool1 = + ChatMessage( + id = "tool-1", + role = MessageRole.TOOL, + content = prefixToolContent, + toolName = "terminal", + toolCallId = "call-1", + ) + val tool2 = + ChatMessage( + id = "tool-2", + role = MessageRole.TOOL, + content = fullToolContent, + toolName = "terminal", + toolCallId = "call-2", + ) + + assertFalse(sameLogicalMessage(tool1, tool2)) + assertFalse(sameLogicalMessage(tool2, tool1)) + } + + // ── Contract 4 ──────────────────────────────────────────────────────────── + // Exact-equal trimmed content still matches as before for a live vs REST pair + // of the same role (sanity). + + @Test + fun contract4_exactEqualTrimmedContent_liveVsRest_matchesSameLogicalMessage() { + for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT)) { + val live = + ChatMessage( + id = "live-temp-id", + role = role, + content = "\n\nHello, this is an important message for testing.\n ", + ) + val rest = + ChatMessage( + id = "rest-s-200", + role = role, + content = "Hello, this is an important message for testing.", + restId = "rest-s-200", + ) + + assertTrue("Role $role exact-equal trimmed content must match", sameLogicalMessage(live, rest)) + assertTrue( + "Role $role exact-equal trimmed content must match symmetrically", + sameLogicalMessage(rest, live), + ) + + val merged = mergeTranscriptWithLive(listOf(rest), listOf(live), preserveLiveIds = true) + assertEquals("Exact-equal trimmed pair should deduplicate into single message", 1, merged.size) + } + } + + // ── Contract 5 ──────────────────────────────────────────────────────────── + // Issue #842 scenario still dedupes: a live sealed interim assistant narration + // without completion/REST identity whose text (after trim) is a prefix of / + // equal-prefix to the persisted REST narration -> treated as same (reuse the + // shape used in ChatHistoryCommentaryTest if it exists). + + @Test + fun contract5_issue842_liveSealedInterimAssistantNarration_prefixOfRestNarration_stillDedupes() { + // Issue #842 / ChatToolDedupeTest pattern: + // Live sealed interim bubble without REST identity raced flush and ends prematurely. + // Rest row contains the full/completed narration. + val liveOrphan = + ChatMessage( + id = "live-orphan-sealed", + role = MessageRole.ASSISTANT, + content = + "\n\ntool's loaded! 🔍 now searchin' for the **best hummus recipe**" + + " — gimme dat creamy dreamy chickpea", + ) + val restRow = + ChatMessage( + id = "rest-s-842", + role = MessageRole.ASSISTANT, + content = + "tool's loaded! 🔍 now searchin' for the **best hummus recipe**" + + " — gimme dat creamy dreamy chickpea goodness:", + restId = "rest-s-842", + ) + + assertTrue(sameLogicalMessage(liveOrphan, restRow)) + assertTrue(sameLogicalMessage(restRow, liveOrphan)) + + val merged = mergeTranscriptWithLive(listOf(restRow), listOf(liveOrphan), preserveLiveIds = true) + assertEquals( + "Live interim orphan should deduplicate against persisted REST narration", + 1, + merged.size, + ) + } +} From 8ecfbef74a7935bb6d8d72e5666be82e97ee1233 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:46:05 +0300 Subject: [PATCH 036/107] test(chat): JVM characterization tests for ChatScrollController (#1319) (#1347) --- .../components/ChatScrollControllerTest.kt | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatScrollControllerTest.kt diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatScrollControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatScrollControllerTest.kt new file mode 100644 index 00000000..60e888d5 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatScrollControllerTest.kt @@ -0,0 +1,169 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatScrollControllerTest { + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun mockLazyListStateAtBottom(): LazyListState { + val listState = mockk(relaxed = true) + val layoutInfo = mockk(relaxed = true) + val itemInfo = mockk(relaxed = true) + + every { layoutInfo.totalItemsCount } returns 5 + every { layoutInfo.viewportEndOffset } returns 1000 + every { layoutInfo.afterContentPadding } returns 0 + every { itemInfo.index } returns 4 + every { itemInfo.offset } returns 800 + every { itemInfo.size } returns 100 + every { layoutInfo.visibleItemsInfo } returns listOf(itemInfo) + every { listState.layoutInfo } returns layoutInfo + + return listState + } + + @Test + fun `initial state has isFollowingBottom true and pendingCount 0`() { + val listState = mockk(relaxed = true) + val scope = TestScope(testDispatcher) + val controller = ChatScrollController(listState, scope) + + assertTrue(controller.isFollowingBottom) + assertEquals(0, controller.pendingCount) + } + + @Test + fun `new tail while following keeps pendingCount 0 and requests scroll`() = + runTest(testDispatcher) { + val listState = mockLazyListStateAtBottom() + val controller = ChatScrollController(listState, this) + + controller.onTailChanged(tailKey = "msg-1", messageCount = 1) + advanceUntilIdle() + + assertTrue(controller.isFollowingBottom) + assertEquals(0, controller.pendingCount) + coVerify(atLeast = 1) { listState.scrollToItem(4, any()) } + } + + @Test + fun `pauseFollowing followed by new messages increments pendingCount`() { + val listState = mockk(relaxed = true) + val scope = TestScope(testDispatcher) + val controller = ChatScrollController(listState, scope) + + controller.pauseFollowing() + assertFalse(controller.isFollowingBottom) + + controller.onTailChanged(tailKey = "msg-1", messageCount = 1) + assertEquals(1, controller.pendingCount) + + controller.onTailChanged(tailKey = "msg-2", messageCount = 3) + assertEquals(3, controller.pendingCount) + assertFalse(controller.isFollowingBottom) + } + + @Test + fun `resumeFollowing clears pendingCount and restores isFollowingBottom`() = + runTest(testDispatcher) { + val listState = mockLazyListStateAtBottom() + val controller = ChatScrollController(listState, this) + + controller.pauseFollowing() + controller.onTailChanged(tailKey = "msg-1", messageCount = 2) + assertEquals(2, controller.pendingCount) + assertFalse(controller.isFollowingBottom) + + controller.resumeFollowing() + advanceUntilIdle() + + assertTrue(controller.isFollowingBottom) + assertEquals(0, controller.pendingCount) + coVerify(atLeast = 1) { listState.animateScrollToItem(4, any()) } + } + + @Test + fun `jumpToBottom clears pendingCount and restores isFollowingBottom`() = + runTest(testDispatcher) { + val listState = mockLazyListStateAtBottom() + val controller = ChatScrollController(listState, this) + + controller.pauseFollowing() + controller.onTailChanged(tailKey = "msg-1", messageCount = 4) + assertEquals(4, controller.pendingCount) + assertFalse(controller.isFollowingBottom) + + controller.jumpToBottom(animated = false) + advanceUntilIdle() + + assertTrue(controller.isFollowingBottom) + assertEquals(0, controller.pendingCount) + coVerify(atLeast = 1) { listState.scrollToItem(4, any()) } + } + + @Test + fun `tail key unchanged causes no change to pendingCount or scroll`() = + runTest(testDispatcher) { + val listState = mockLazyListStateAtBottom() + val controller = ChatScrollController(listState, this) + + controller.pauseFollowing() + controller.onTailChanged(tailKey = "same-key", messageCount = 1) + assertEquals(1, controller.pendingCount) + + // Repeating the same tailKey should be a no-op + controller.onTailChanged(tailKey = "same-key", messageCount = 5) + assertEquals(1, controller.pendingCount) + + advanceUntilIdle() + coVerify(exactly = 0) { listState.scrollToItem(any(), any()) } + coVerify(exactly = 0) { listState.animateScrollToItem(any(), any()) } + } + + @Test + fun `showFab returns true only when paused and content is present`() { + val listState = mockk(relaxed = true) + val scope = TestScope(testDispatcher) + val controller = ChatScrollController(listState, scope) + + // Following bottom -> false regardless of contentPresent + assertFalse(controller.showFab(contentPresent = true)) + assertFalse(controller.showFab(contentPresent = false)) + + controller.pauseFollowing() + // Paused -> true only if contentPresent + assertTrue(controller.showFab(contentPresent = true)) + assertFalse(controller.showFab(contentPresent = false)) + } +} From 4fc4d097ccb196570fe57206f9a1a6b060912b7f Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:49:09 +0300 Subject: [PATCH 037/107] perf(chat): memoize streaming typing-effect text (#1315) (#1345) --- .../ui/chat/fullbleed/FullBleedStreaming.kt | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedStreaming.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedStreaming.kt index 5733834b..572ccb89 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedStreaming.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedStreaming.kt @@ -71,19 +71,31 @@ internal fun StreamingFullBleedWithTypingEffect( } // Derive display text from the latest full content at each recomposition - val words = streaming.content.split(" ") + val words = remember(streaming.content) { streaming.content.split(" ") } val visibleCount = if (visibleWordCount >= Int.MAX_VALUE / 2) { words.size } else { visibleWordCount.coerceIn(0, words.size) } + val isStreaming = currentIsStreaming.value val displayText = - words.take(visibleCount.coerceAtLeast(1)).joinToString(" ") + - if (caretVisible && currentIsStreaming.value) "▍" else "" + remember(words, visibleCount, caretVisible, isStreaming) { + words.take(visibleCount.coerceAtLeast(1)).joinToString(" ") + + if (caretVisible && isStreaming) "▍" else "" + } + + val displayMessage = + remember(streaming, displayText) { + if (streaming.content == displayText) { + streaming + } else { + streaming.copy(content = displayText) + } + } FullBleedAgentMessage( - message = streaming.copy(content = displayText), + message = displayMessage, showReasoning = showReasoning, searchQuery = "", isCurrentMatch = false, From 18953e368b3fa73ae6f82070bf727b2d614d4ca1 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:49:17 +0300 Subject: [PATCH 038/107] perf(chat): memoize inline LaTeX and match bare URLs once (#1316) (#1342) --- .../ui/chat/markdown/MarkdownInlineStyler.kt | 10 ++- .../ui/chat/markdown/MarkdownInlineText.kt | 76 ++++++++++++------- 2 files changed, 53 insertions(+), 33 deletions(-) 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 79c83b0c..97cc6c41 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 @@ -68,6 +68,9 @@ object MarkdownInlineStyler { val src = text while (i < src.length) { + // Cheap prefix gate: URL_PATTERN only matches at "http(s)://", so skip the regex elsewhere. + val urlMatch = + if (src.startsWith("http", i, ignoreCase = true)) URL_PATTERN.matchAt(src, i) else null when { // Inline code is opaque to emphasis parsing, and delimiters must match by run length. src[i] == '`' -> { @@ -312,9 +315,8 @@ object MarkdownInlineStyler { } // bare URL - URL_PATTERN.matchAt(src, i) != null -> { - val match = URL_PATTERN.matchAt(src, i)!! - val url = match.value + urlMatch != null -> { + val url = urlMatch.value val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url pushLink(LinkAnnotation.Url(url)) withStyle( @@ -326,7 +328,7 @@ object MarkdownInlineStyler { append(urlToAppend) } pop() - i = match.range.last + 1 + i = urlMatch.range.last + 1 } // Plain text / words in RTL diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt index 167ca1e1..6270472b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt @@ -66,38 +66,56 @@ fun MarkdownInlineText( } val config = - LatexConfig( - fontSize = style.fontSize, - theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent), - accessibilityEnabled = true, - ) - val inlineContent = mutableMapOf() - segments.forEachIndexed { index, segment -> - if (segment is InlineMathSegment.Math) { - latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it } + remember(style.fontSize, textColor) { + LatexConfig( + fontSize = style.fontSize, + theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent), + accessibilityEnabled = true, + ) } - } - val annotated = - buildAnnotatedString { + val inlineContent = + remember(segments, latexMeasurer, config) { + val map = mutableMapOf() segments.forEachIndexed { index, segment -> - when (segment) { - is InlineMathSegment.Math -> { - val id = "latex-$index" - if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex) - } + if (segment is InlineMathSegment.Math) { + latexMeasurer.inlineContent(segment.latex, config)?.let { map["latex-$index"] = it } + } + } + map + } + val annotated = + remember( + segments, + inlineContent, + textColor, + searchQuery, + isCurrentMatch, + linkColor, + highlights, + isRtl, + ) { + buildAnnotatedString { + segments.forEachIndexed { index, segment -> + when (segment) { + is InlineMathSegment.Math -> { + val id = "latex-$index" + val measured = id in inlineContent + if (measured) appendInlineContent(id, segment.latex) else append(segment.latex) + } - is InlineMathSegment.Text -> { - append( - MarkdownInlineStyler.parseInlineSource( - text = segment.value, - textColor = textColor, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - isRtl = isRtl, - ), - ) + is InlineMathSegment.Text -> { + append( + MarkdownInlineStyler.parseInlineSource( + text = segment.value, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = isRtl, + ), + ) + } } } } From 673c53503fae93eeaf19e80820f1cd0502c2ee81 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:49:25 +0300 Subject: [PATCH 039/107] perf(chat): clamp raw tool JSON and hoist tool regexes (#1318) (#1343) --- .../m57/hermescontrol/ui/chat/ToolBubble.kt | 21 ++++++++--- .../hermescontrol/ui/chat/tool/ToolCounts.kt | 37 ++++++++++++------- .../ui/chat/tool/ToolResultSummary.kt | 17 ++++++--- 3 files changed, 51 insertions(+), 24 deletions(-) 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 8e09d120..5aa93e35 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 @@ -630,12 +630,23 @@ private fun ToolRawJsonView( modifier: Modifier = Modifier, ) { var formatJson by remember { mutableStateOf(true) } - val formattedContent = remember(rawContent) { ToolJson.prettyPrintJson(rawContent) } - val isFormatDifferent = formattedContent != rawContent - val displayText = if (formatJson && isFormatDifferent) formattedContent else rawContent + val clampedRaw = remember(rawContent) { ToolJson.clampForDisplay(rawContent) } + + // Full pretty text (for copy) + clamped copy (for display); null when formatting changes nothing. + val formatted by produceState?>(initialValue = null, key1 = rawContent) { + value = + withContext(Dispatchers.Default) { + val full = ToolJson.prettyPrintJson(rawContent) + if (full != rawContent) full to ToolJson.clampForDisplay(full) else null + } + } + + val isFormatDifferent = formatted != null + val displayText = formatted?.takeIf { formatJson }?.second ?: clampedRaw + val copyText = formatted?.takeIf { formatJson }?.first ?: rawContent val highlighted by produceState( - initialValue = remember(displayText) { AnnotatedString(displayText) }, + initialValue = remember(clampedRaw) { AnnotatedString(clampedRaw) }, key1 = displayText, ) { value = @@ -645,7 +656,7 @@ private fun ToolRawJsonView( } CodeTerminalCard( - textToCopy = displayText, + textToCopy = copyText, modifier = modifier, testTag = "tool_raw_json", title = "JSON", diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt index fc0fd5be..dbd48a6a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt @@ -84,6 +84,23 @@ internal object ToolCounts { "sources" to "source", ) + private val S_ENDING_NOUN_REGEX = Regex("(xes|zes|ches|shes|sses)$") + private val CONSONANT_Y_NOUN_REGEX = Regex("[aeiou]y$", RegexOption.IGNORE_CASE) + private val SIBILANT_NOUN_REGEX = Regex("(s|x|z|ch|sh)$", RegexOption.IGNORE_CASE) + private val COUNT_OR_TOTAL_KEY_REGEX = Regex("_count$|_total$") + private val COUNT_OR_TOTAL_STRIP_REGEX = Regex("_(count|total)$") + private val NUM_PREFIX_STRIP_REGEX = Regex("^num_") + private val UNIT_COUNT_REGEX = + Regex( + """\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""", + RegexOption.IGNORE_CASE, + ) + private val VERB_COUNT_REGEX = + Regex( + """\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""", + RegexOption.IGNORE_CASE, + ) + private fun countFromUnknown(value: JsonElement?): Int? { if (value is JsonArray) { return if (value.isNotEmpty()) value.size else null @@ -109,7 +126,7 @@ internal object ToolCounts { return "${normalized.dropLast(3)}y" } - if (Regex("(xes|zes|ches|shes|sses)$").containsMatchIn(normalized) && normalized.length > 3) { + if (S_ENDING_NOUN_REGEX.containsMatchIn(normalized) && normalized.length > 3) { return normalized.dropLast(2) } @@ -134,12 +151,12 @@ internal object ToolCounts { if (noun.endsWith( "y", - ) && noun.length > 1 && !Regex("[aeiou]y$", RegexOption.IGNORE_CASE).containsMatchIn(noun) + ) && noun.length > 1 && !CONSONANT_Y_NOUN_REGEX.containsMatchIn(noun) ) { return "${noun.dropLast(1)}ies" } - if (Regex("(s|x|z|ch|sh)$", RegexOption.IGNORE_CASE).containsMatchIn(noun)) { + if (SIBILANT_NOUN_REGEX.containsMatchIn(noun)) { return "${noun}es" } @@ -171,12 +188,12 @@ internal object ToolCounts { if (key in COUNT_EXCLUDED_KEYS) { continue } - if (!Regex("_count$|_total$").containsMatchIn(key)) { + if (!COUNT_OR_TOTAL_KEY_REGEX.containsMatchIn(key)) { continue } val count = countFromUnknown(value) ?: continue - val stripped = key.lowercase().replace(Regex("_(count|total)$"), "").replace(Regex("^num_"), "") + val stripped = key.lowercase().replace(COUNT_OR_TOTAL_STRIP_REGEX, "").replace(NUM_PREFIX_STRIP_REGEX, "") return countMetric(count, singularizeNoun(stripped).ifEmpty { fallbackNoun }) } @@ -194,14 +211,8 @@ internal object ToolCounts { } val unitMatch = - Regex( - """\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""", - RegexOption.IGNORE_CASE, - ).find(t) - ?: Regex( - """\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""", - RegexOption.IGNORE_CASE, - ).find(t) + UNIT_COUNT_REGEX.find(t) + ?: VERB_COUNT_REGEX.find(t) val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt index 24f5ee9f..2535397a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt @@ -49,6 +49,14 @@ object ToolResultSummary { private val ERROR_MSG_KEYS = listOf("message", "reason", "detail") private val NON_ERROR_TEXT = setOf("", "0", "false", "none", "null", "nil", "ok", "success", "n/a", "na") + private val KEY_SPLIT_REGEX = Regex("[_\\-.]+") + private val WHITESPACE_REGEX = Regex("\\s+") + private val ERROR_SIGNAL_STATUS_REGEX = + Regex( + "\\b(error|failed|failure|fatal|exception)\\b", + RegexOption.IGNORE_CASE, + ) + // ── helpers ────────────────────────────────────────────────────────── private fun normalize(value: String): String = value.lowercase().trim() @@ -58,7 +66,7 @@ object ToolResultSummary { private fun titleCase(key: String): String = key - .split(Regex("[_\\-.]+")) + .split(KEY_SPLIT_REGEX) .filter { it.isNotEmpty() } .joinToString(" ") { capitalize(it) } @@ -72,7 +80,7 @@ object ToolResultSummary { value: String, max: Int = 180, ): String { - val c = value.replace(Regex("\\s+"), " ").trim() + val c = value.replace(WHITESPACE_REGEX, " ").trim() return if (c.length > max) "${c.take(max - 1)}…" else c } @@ -456,10 +464,7 @@ object ToolResultSummary { return ( (record["success"] as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true || (record["ok"] as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true || - Regex( - "\\b(error|failed|failure|fatal|exception)\\b", - RegexOption.IGNORE_CASE, - ).containsMatchIn(status) || + ERROR_SIGNAL_STATUS_REGEX.containsMatchIn(status) || ERROR_KEYS.any { k -> hasMeaningfulErrorValue(record[k]) } ) } From 6a00696e68d09f1c1114c5e0ca6c813dc680e611 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:49:33 +0300 Subject: [PATCH 040/107] perf(chat): add contentType to transcript list items (#1314) (#1344) --- .../ui/chat/fullbleed/FullBleedChatList.kt | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) 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 0deaff27..0a72c056 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 @@ -53,6 +53,14 @@ import com.m57.hermescontrol.ui.chat.components.VaultUnlockCard import com.m57.hermescontrol.ui.chat.toolCallMilestones import com.m57.hermescontrol.ui.common.EmptyState +private object FullBleedContentType { + const val USER: String = "user" + const val REASONING: String = "reasoning" + const val PROSE: String = "prose" + const val TOOL: String = "tool" + const val SYSTEM_EVENT: String = "system_event" +} + /** * The chat message list for FULL-BLEED style (issue #866) — the single chat * surface since the bubble renderer was removed. User messages keep their @@ -321,7 +329,10 @@ fun FullBleedChatList( // Eager captures: item lambda reads these at // composition time (lazy), so capture now. val userMessage = turn.message - item(key = "user-${userMessage.id}") { + item( + key = "user-${userMessage.id}", + contentType = FullBleedContentType.USER, + ) { Column(modifier = Modifier.padding(bottom = 12.dp)) { renderChatBubble( message = userMessage, @@ -352,7 +363,10 @@ fun FullBleedChatList( .firstOrNull { it.message.reasoningText.isNotBlank() } if (turnReasoning != null) { val reasoning = turnReasoning.message - item(key = "reasoning-${reasoning.id}") { + item( + key = "reasoning-${reasoning.id}", + contentType = FullBleedContentType.REASONING, + ) { Column(modifier = Modifier.padding(bottom = 6.dp)) { ReasoningCard( reasoningText = reasoning.reasoningText, @@ -371,7 +385,10 @@ fun FullBleedChatList( if (!proseMessage.hasVisibleAgentContent() && !hoistedReasoning && proseMessage.reasoningText.isNotBlank() ) { - item(key = "reasoning-${proseMessage.id}") { + item( + key = "reasoning-${proseMessage.id}", + contentType = FullBleedContentType.REASONING, + ) { ReasoningCard( reasoningText = proseMessage.reasoningText, isStreaming = proseMessage.isStreaming, @@ -379,7 +396,10 @@ fun FullBleedChatList( } } if (proseMessage.hasVisibleAgentContent()) { - item(key = "prose-${proseMessage.id}") { + item( + key = "prose-${proseMessage.id}", + contentType = FullBleedContentType.PROSE, + ) { Column(modifier = Modifier.padding(bottom = 12.dp)) { if (proseMessage.isStreaming && typingEffectEnabled) { StreamingFullBleedWithTypingEffect( @@ -434,7 +454,10 @@ fun FullBleedChatList( is AgentEntry.ToolRow -> { val toolMessage = entry.message val milestone = toolMilestones[toolMessage.id] - item(key = "tool-${toolMessage.id}") { + item( + key = "tool-${toolMessage.id}", + contentType = FullBleedContentType.TOOL, + ) { Column(modifier = Modifier.padding(bottom = 6.dp)) { FullBleedToolRow(toolMessage) milestone?.let { count -> @@ -446,7 +469,10 @@ fun FullBleedChatList( is AgentEntry.SystemEvent -> { val sysMessage = entry.message - item(key = "sys-${sysMessage.id}") { + item( + key = "sys-${sysMessage.id}", + contentType = FullBleedContentType.SYSTEM_EVENT, + ) { Column(modifier = Modifier.padding(bottom = 6.dp)) { if (sysMessage.displayKind != null) { // Timeline marker (issue #904): @@ -469,7 +495,11 @@ fun FullBleedChatList( } tailItems.forEach { (key, content) -> - item(key = key) { content() } + item( + key = key, + // Tail keys are fixed per kind, so the key doubles as the slot type. + contentType = key, + ) { content() } } } // Loading history must never become the list's first visible anchor. From 4bfc015cffcef31dee83f4d8290ac428e4b9e5a7 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:49:41 +0300 Subject: [PATCH 041/107] chore(chat): remove dead transcript code (#1309) (#1341) --- .../chat/fullbleed/FullBleedChatAnchorTest.kt | 2 - .../chat/fullbleed/FullBleedChatListTest.kt | 2 - .../ui/chat/ChatMediaDelegate.kt | 12 ---- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 3 - .../m57/hermescontrol/ui/chat/MarkdownText.kt | 9 ++- .../ui/chat/ToolSchemaRegistry.kt | 51 ---------------- .../ui/chat/components/InlineMediaPlayer.kt | 15 +++++ .../ui/chat/components/InlineVideoPlayer.kt | 35 ----------- .../ui/chat/components/MessageCards.kt | 61 ------------------- .../ui/chat/components/VideoViewerDialog.kt | 17 ------ .../ui/chat/fullbleed/FullBleedChatList.kt | 2 - 11 files changed, 19 insertions(+), 190 deletions(-) delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt index dfbf9241..58d09b5c 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt @@ -133,8 +133,6 @@ class FullBleedChatAnchorTest { isLoadingOlder = loading.value, listState = listState, scrollController = controller, - lastAnimatedMessageId = null, - onLastAnimatedMessageIdChange = {}, viewModel = viewModel, pagingSessionId = session.value, clarifyRequest = if (tailKey != null) ClarifyUi("Choose an option", listOf("Yes")) else null, diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt index 9a4ef7f1..b55870f2 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt @@ -94,8 +94,6 @@ class FullBleedChatListTest { listState = listState, scope = CoroutineScope(Dispatchers.Main.immediate), ), - lastAnimatedMessageId = null, - onLastAnimatedMessageIdChange = {}, viewModel = mockk(relaxed = true), clarifyRequest = if (clarify) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt index 6e2bab35..bb8f0afe 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt @@ -272,15 +272,3 @@ class ChatMediaDelegate( uiState.update { it.copy(openError = null) } } } - -internal fun sameMessages( - left: List, - right: List, -): Boolean = - left.size == right.size && - left.zip(right).all { (a, b) -> - a.id == b.id && - a.role == b.role && - a.content == b.content && - a.reasoningText == b.reasoningText - } 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 1568e8dd..1288de73 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 @@ -375,7 +375,6 @@ fun ChatScreen( viewModel.consumeComposerTextRestore() } } - var lastAnimatedMessageId by rememberSaveable { mutableStateOf(null) } var showReloginDialog by rememberSaveable { mutableStateOf(false) } var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) } LaunchedEffect(showSubagentInspectionSheet) { @@ -783,8 +782,6 @@ fun ChatScreen( }, listState = listState, scrollController = scrollController, - lastAnimatedMessageId = lastAnimatedMessageId, - onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it }, viewModel = viewModel, clarifyRequest = state.clarifyRequest.takeUnless { timelineState.isHistorical }, onRespondClarify = viewModel::respondToClarify, 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 836712b6..a3453697 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 @@ -74,7 +74,6 @@ import com.m57.hermescontrol.ui.chat.markdown.splitInlineMath import com.m57.hermescontrol.util.BidiUtils private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""") -private val TABLE_COL_WIDTH = 160.dp private fun bidiTextDirection(isRtl: Boolean): TextDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr @@ -359,13 +358,13 @@ fun MarkdownText( .fillMaxWidth() .padding(vertical = 4.dp), ) { - com.m57.hermescontrol.ui.chat.components.InlineVideoPlayer( - videoUri = resolvedUri, + com.m57.hermescontrol.ui.chat.components.InlineMediaPlayer( + uri = resolvedUri, onFullScreenClick = { showVideoDialog = true }, ) if (showVideoDialog) { - com.m57.hermescontrol.ui.chat.components.VideoViewerDialog( - videoUri = resolvedUri, + com.m57.hermescontrol.ui.chat.components.MediaViewerDialog( + mediaUri = resolvedUri, onDismissRequest = { showVideoDialog = false }, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt index 4af0ace6..22afc91d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt @@ -40,10 +40,6 @@ import androidx.compose.ui.graphics.vector.ImageVector */ data class ToolDisplayConfig( val name: String, - /** Key into the `args` dict for the one-line summary (e.g. "command" for terminal). */ - val summaryArgKey: String? = null, - /** Prefix for the summary line (e.g. "$ " for terminal). Emoji-free. */ - val summaryPrefix: String = "", /** Material icon for this tool type. */ val icon: ImageVector = Icons.Filled.Build, ) @@ -55,285 +51,238 @@ object ToolSchemaRegistry { "terminal" to ToolDisplayConfig( name = "terminal", - summaryArgKey = "command", - summaryPrefix = "$ ", icon = Icons.Filled.Terminal, ), "read_file" to ToolDisplayConfig( name = "read_file", - summaryArgKey = "path", icon = Icons.Filled.Description, ), "write_file" to ToolDisplayConfig( name = "write_file", - summaryArgKey = "path", icon = Icons.Filled.Edit, ), "patch" to ToolDisplayConfig( name = "patch", - summaryArgKey = "path", icon = Icons.Filled.Build, ), "search_files" to ToolDisplayConfig( name = "search_files", - summaryArgKey = "pattern", icon = Icons.Filled.Search, ), "web_search" to ToolDisplayConfig( name = "web_search", - summaryArgKey = "query", icon = Icons.Filled.Public, ), "browser_navigate" to ToolDisplayConfig( name = "browser_navigate", - summaryArgKey = "url", icon = Icons.Filled.Language, ), "browser_click" to ToolDisplayConfig( name = "browser_click", - summaryArgKey = "ref", icon = Icons.Filled.TouchApp, ), "browser_snapshot" to ToolDisplayConfig( name = "browser_snapshot", - summaryArgKey = null, icon = Icons.Filled.Photo, ), "clarify" to ToolDisplayConfig( name = "clarify", - summaryArgKey = "question", icon = Icons.Filled.ChatBubble, ), "delegate_task" to ToolDisplayConfig( name = "delegate_task", - summaryArgKey = "goal", icon = Icons.Filled.AccountTree, ), "execute_code" to ToolDisplayConfig( name = "execute_code", - summaryArgKey = "code", icon = Icons.Filled.PlayArrow, ), "todo" to ToolDisplayConfig( name = "todo", - summaryArgKey = null, icon = Icons.Filled.Checklist, ), "todo_list" to ToolDisplayConfig( name = "todo_list", - summaryArgKey = null, icon = Icons.Filled.Checklist, ), "fact_store" to ToolDisplayConfig( name = "fact_store", - summaryArgKey = null, icon = Icons.Filled.Psychology, ), "session_search" to ToolDisplayConfig( name = "session_search", - summaryArgKey = null, icon = Icons.Filled.Search, ), // ── Action-based tools ── "cronjob" to ToolDisplayConfig( name = "cronjob", - summaryArgKey = "action", icon = Icons.Filled.Schedule, ), "cronjob_manage" to ToolDisplayConfig( name = "cronjob_manage", - summaryArgKey = "action", icon = Icons.Filled.Schedule, ), "memory" to ToolDisplayConfig( name = "memory", - summaryArgKey = "action", icon = Icons.Filled.Memory, ), "fact_feedback" to ToolDisplayConfig( name = "fact_feedback", - summaryArgKey = "action", icon = Icons.Filled.ThumbUp, ), "process" to ToolDisplayConfig( name = "process", - summaryArgKey = "action", icon = Icons.Filled.Settings, ), "process_manage" to ToolDisplayConfig( name = "process_manage", - summaryArgKey = "action", icon = Icons.Filled.Settings, ), "skill_manage" to ToolDisplayConfig( name = "skill_manage", - summaryArgKey = "action", icon = Icons.Filled.Build, ), // ── Web / Browser tools ── "web_extract" to ToolDisplayConfig( name = "web_extract", - summaryArgKey = "urls", icon = Icons.Filled.Language, ), "browser_type" to ToolDisplayConfig( name = "browser_type", - summaryArgKey = null, icon = Icons.Filled.Keyboard, ), "browser_scroll" to ToolDisplayConfig( name = "browser_scroll", - summaryArgKey = "direction", icon = Icons.Filled.TouchApp, ), "browser_back" to ToolDisplayConfig( name = "browser_back", - summaryArgKey = null, icon = Icons.Filled.Language, ), "browser_press" to ToolDisplayConfig( name = "browser_press", - summaryArgKey = "key", icon = Icons.Filled.Keyboard, ), "browser_get_images" to ToolDisplayConfig( name = "browser_get_images", - summaryArgKey = null, icon = Icons.Filled.Image, ), "browser_vision" to ToolDisplayConfig( name = "browser_vision", - summaryArgKey = "question", icon = Icons.Filled.Visibility, ), "browser_console" to ToolDisplayConfig( name = "browser_console", - summaryArgKey = null, icon = Icons.Filled.Terminal, ), "browser_cdp" to ToolDisplayConfig( name = "browser_cdp", - summaryArgKey = "action", icon = Icons.Filled.Build, ), "browser_dialog" to ToolDisplayConfig( name = "browser_dialog", - summaryArgKey = "action", icon = Icons.Filled.ChatBubble, ), // ── Media / Vision tools ── "vision_analyze" to ToolDisplayConfig( name = "vision_analyze", - summaryArgKey = "image_url", icon = Icons.Filled.Visibility, ), "text_to_speech" to ToolDisplayConfig( name = "text_to_speech", - summaryArgKey = null, icon = Icons.AutoMirrored.Filled.VolumeUp, ), "video_generate" to ToolDisplayConfig( name = "video_generate", - summaryArgKey = null, icon = Icons.Filled.Movie, ), "image_generate" to ToolDisplayConfig( name = "image_generate", - summaryArgKey = null, icon = Icons.Filled.Image, ), // ── Social / Messaging tools ── "x_search" to ToolDisplayConfig( name = "x_search", - summaryArgKey = "query", icon = Icons.Filled.Forum, ), "send_message" to ToolDisplayConfig( name = "send_message", - summaryArgKey = null, icon = Icons.AutoMirrored.Filled.Send, ), // ── Skills tools ── "skills_list" to ToolDisplayConfig( name = "skills_list", - summaryArgKey = null, icon = Icons.AutoMirrored.Filled.MenuBook, ), "skill_view" to ToolDisplayConfig( name = "skill_view", - summaryArgKey = "name", icon = Icons.Filled.AutoStories, ), // ── On-demand tools ── "tool_search" to ToolDisplayConfig( name = "tool_search", - summaryArgKey = "query", icon = Icons.Filled.Search, ), "tool_describe" to ToolDisplayConfig( name = "tool_describe", - summaryArgKey = "name", icon = Icons.Filled.AutoStories, ), "tool_call" to ToolDisplayConfig( name = "tool_call", - summaryArgKey = "name", icon = Icons.Filled.Build, ), // ── Misc ── "read_terminal" to ToolDisplayConfig( name = "read_terminal", - summaryArgKey = "session_id", icon = Icons.Filled.Computer, ), "computer_use" to ToolDisplayConfig( name = "computer_use", - summaryArgKey = "action", icon = Icons.Filled.Computer, ), ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt index 972f4399..702d490a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt @@ -209,3 +209,18 @@ private fun InlineVideoPreview( } } } + +/** Formats milliseconds into `M:SS` or `H:MM:SS` */ +fun formatMediaDuration(durationMs: Long): String { + if (durationMs <= 0) return "0:00" + val totalSeconds = durationMs / 1000 + val seconds = totalSeconds % 60 + val minutes = (totalSeconds / 60) % 60 + val hours = totalSeconds / 3600 + + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%d:%02d".format(minutes, seconds) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt deleted file mode 100644 index 15ed878c..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.m57.hermescontrol.ui.chat.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -/** - * Backwards-compatible wrapper delegating to [InlineMediaPlayer]. - */ -@Composable -fun InlineVideoPlayer( - videoUri: String, - modifier: Modifier = Modifier, - onFullScreenClick: () -> Unit = {}, -) { - InlineMediaPlayer( - uri = videoUri, - modifier = modifier, - onFullScreenClick = onFullScreenClick, - ) -} - -/** Formats milliseconds into `M:SS` or `H:MM:SS` */ -fun formatMediaDuration(durationMs: Long): String { - if (durationMs <= 0) return "0:00" - val totalSeconds = durationMs / 1000 - val seconds = totalSeconds % 60 - val minutes = (totalSeconds / 60) % 60 - val hours = totalSeconds / 3600 - - return if (hours > 0) { - "%d:%02d:%02d".format(hours, minutes, seconds) - } else { - "%d:%02d".format(minutes, seconds) - } -} 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 df4038d9..0c3ed069 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 @@ -90,7 +90,6 @@ 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 import kotlinx.coroutines.withContext @@ -400,14 +399,6 @@ private data class TokenPattern( val color: Color, ) -private fun copyToClipboard( - context: Context, - text: String, -) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return - clipboard.setPrimaryClip(ClipData.newPlainText(null, text)) -} - // ── ClarifyBubble ───────────────────────────────────────────────────────── /** @@ -670,58 +661,6 @@ fun ClarifyBubble( ) } -// ── SubagentCard ────────────────────────────────────────────────────────── - -/** - * Inline card showing subagent task progress. - * - * - While running: [CircularProgressIndicator] + goal text on [tertiaryContainer]. - * - On completion: ✅ checkmark + summary text. - */ -@Composable -fun SubagentCard( - indicator: SubagentIndicator, - modifier: Modifier = Modifier, -) { - val isComplete = indicator.type == "subagent.complete" - Surface( - modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 2.dp).testTag("subagent_card"), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.tertiaryContainer, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (isComplete) { - Text(text = "✅", fontSize = 14.sp) - } else { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onTertiaryContainer, - ) - } - Spacer(Modifier.width(8.dp)) - val displayText = - if (isComplete && !indicator.summary.isNullOrBlank()) { - indicator.summary - } else if (!indicator.goal.isNullOrBlank()) { - indicator.goal - } else { - "Subagent task" - } - Text( - text = displayText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onTertiaryContainer, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - // ── TypingIndicator ─────────────────────────────────────────────────────── /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt deleted file mode 100644 index 429b5230..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.m57.hermescontrol.ui.chat.components - -import androidx.compose.runtime.Composable - -/** - * Backwards-compatible wrapper delegating to [MediaViewerDialog]. - */ -@Composable -fun VideoViewerDialog( - videoUri: String, - onDismissRequest: () -> Unit, -) { - MediaViewerDialog( - mediaUri = videoUri, - onDismissRequest = onDismissRequest, - ) -} 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 0a72c056..3016ec59 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 @@ -93,8 +93,6 @@ fun FullBleedChatList( isLoadingOlder: Boolean, listState: androidx.compose.foundation.lazy.LazyListState, scrollController: ChatScrollController, - lastAnimatedMessageId: String?, - onLastAnimatedMessageIdChange: (String?) -> Unit, viewModel: ChatViewModel, clarifyRequest: ClarifyUi? = null, onRespondClarify: ((String) -> Unit)? = null, From 65d6f98262723590d730cdbeff869aa6693451f1 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:56:20 +0300 Subject: [PATCH 042/107] fix(chat): key inline video dialog state by uri (#1317) (#1346) --- app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a3453697..e52b742f 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 @@ -351,7 +351,7 @@ fun MarkdownText( is MdBlock.Video -> { val resolvedUri = remember(block.uri) { MarkdownMediaResolver.resolveImageUrl(block.uri) } - var showVideoDialog by remember { mutableStateOf(false) } + var showVideoDialog by remember(block.uri) { mutableStateOf(false) } androidx.compose.foundation.layout.Box( modifier = Modifier From f2675e0b1a68ae9956cf6d513738f7f794818f32 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:31:04 +0300 Subject: [PATCH 043/107] refactor(chat): split transcript component files (#1310, #1311, #1312) (#1349) --- .../ui/chat/components/ClarifyBubbleTest.kt | 162 ++++ .../ui/chat/components/CopyFeedbackTest.kt | 165 ++++ .../m57/hermescontrol/ui/chat/ChatBubble.kt | 696 ----------------- .../ui/chat/ExpandedToolContent.kt | 237 ++++++ .../hermescontrol/ui/chat/InlineAttachment.kt | 156 ++++ .../hermescontrol/ui/chat/MessageTimestamp.kt | 16 + .../m57/hermescontrol/ui/chat/SystemBubble.kt | 272 +++++++ .../m57/hermescontrol/ui/chat/ToolBubble.kt | 401 +--------- .../hermescontrol/ui/chat/ToolBubbleHeader.kt | 128 +++ .../hermescontrol/ui/chat/ToolRawJsonView.kt | 111 +++ .../m57/hermescontrol/ui/chat/UserBubble.kt | 301 ++++++++ .../ui/chat/components/ClarifyBubble.kt | 301 ++++++++ .../ui/chat/components/CodeBlockCard.kt | 61 ++ .../chat/components/CodeSyntaxHighlighter.kt | 103 +++ .../ui/chat/components/CopyFeedback.kt | 29 + .../ui/chat/components/MessageCards.kt | 728 ------------------ .../ui/chat/components/ReasoningCard.kt | 217 ++++++ .../ui/chat/components/TypingIndicator.kt | 94 +++ .../chat/fullbleed/FullBleedAgentMessage.kt | 16 +- .../ui/chat/fullbleed/FullBleedChatList.kt | 8 +- 20 files changed, 2361 insertions(+), 1841 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubbleTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/CopyFeedbackTest.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ExpandedToolContent.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/InlineAttachment.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/MessageTimestamp.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/SystemBubble.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ToolRawJsonView.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubble.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeSyntaxHighlighter.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/CopyFeedback.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/TypingIndicator.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubbleTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubbleTest.kt new file mode 100644 index 00000000..2c8bc33a --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubbleTest.kt @@ -0,0 +1,162 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +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.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.text.AnnotatedString +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.ui.chat.ClarifyQuestionUi +import com.m57.hermescontrol.ui.chat.ClarifyUi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ClarifyBubbleTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun clickingProvidedChoice_invokesOnRespondSingleWithExactChoice() { + var respondedSingle: String? = null + var respondedBatch: Map? = null + var dismissed = false + + val clarifyRequest = + ClarifyUi( + text = "Which environment should be targeted?", + options = listOf("Staging", "Production"), + clarifyId = "req-1", + ) + + compose.setContent { + HermesControlTheme { + ClarifyBubble( + clarifyRequest = clarifyRequest, + onRespondSingle = { respondedSingle = it }, + onRespondBatch = { respondedBatch = it }, + onDismiss = { dismissed = true }, + ) + } + } + + compose.onNodeWithText("Staging").assertIsDisplayed().performClick() + + compose.runOnIdle { + assertEquals("Staging", respondedSingle) + assertNull(respondedBatch) + assertEquals(false, dismissed) + } + } + + @Test + fun clickingDismiss_invokesOnDismissWithoutResponse() { + var respondedSingle: String? = null + var respondedBatch: Map? = null + var dismissed = false + + val dismissLabel = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.chat_dismiss) + + val clarifyRequest = + ClarifyUi( + text = "Do you want to run database migrations?", + options = listOf("Yes", "No"), + clarifyId = "req-2", + ) + + compose.setContent { + HermesControlTheme { + ClarifyBubble( + clarifyRequest = clarifyRequest, + onRespondSingle = { respondedSingle = it }, + onRespondBatch = { respondedBatch = it }, + onDismiss = { dismissed = true }, + ) + } + } + + compose.onNodeWithText(dismissLabel).assertIsDisplayed().performClick() + + compose.runOnIdle { + assertEquals(true, dismissed) + assertNull(respondedSingle) + assertNull(respondedBatch) + } + } + + @Test + fun replacingRequestIdentity_resetsEnteredTextAndSelection() { + val sharedQuestion = + ClarifyQuestionUi( + qid = "q-common", + question = "Which build targets and extras should be enabled?", + choices = listOf("APK", "AAB", "Native Symbols"), + multiSelect = true, + ) + + var currentRequest by mutableStateOf( + ClarifyUi( + text = sharedQuestion.question, + clarifyId = "req-1", + questions = listOf(sharedQuestion), + ), + ) + + compose.setContent { + HermesControlTheme { + ClarifyBubble( + clarifyRequest = currentRequest, + onRespondSingle = {}, + onRespondBatch = {}, + onDismiss = {}, + ) + } + } + + compose + .onNodeWithText("APK") + .assertIsDisplayed() + .assertIsNotSelected() + .performClick() + compose.onNodeWithText("APK").assertIsSelected() + + val textInputNode = compose.onNode(hasSetTextAction()).assertIsDisplayed() + textInputNode.performTextInput("--dry-run") + textInputNode.assert(hasEditableText("--dry-run")) + + // Replace request identity while retaining identical qid, question, and options + compose.runOnIdle { + currentRequest = + ClarifyUi( + text = sharedQuestion.question, + clarifyId = "req-2", + questions = listOf(sharedQuestion), + ) + } + + // Selected choice and typed text must reset under new clarifyId + compose.onNodeWithText("APK").assertIsDisplayed().assertIsNotSelected() + compose.onNode(hasSetTextAction()).assertIsDisplayed().assert(hasEditableText("")) + } + + // The field merges its placeholder label into Text, so assertTextEquals can + // never match; compare only the typed value. + private fun hasEditableText(value: String) = + SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString(value)) +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/CopyFeedbackTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/CopyFeedbackTest.kt new file mode 100644 index 00000000..43b45c67 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/CopyFeedbackTest.kt @@ -0,0 +1,165 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +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 CopyFeedbackTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Composable + private fun CopyFeedbackHarness( + tagPrefix: String, + stateReceiver: (MutableState) -> Unit = {}, + ) { + val feedbackState = rememberCopyFeedback() + stateReceiver(feedbackState) + + Column { + Text( + text = if (feedbackState.value) "Copied" else "Idle", + modifier = Modifier.testTag("${tagPrefix}_status"), + ) + Text( + text = "Trigger", + modifier = + Modifier + .testTag("${tagPrefix}_trigger") + .clickable { feedbackState.value = true }, + ) + } + } + + @Test + fun initialStateIsFalse() { + composeTestRule.setContent { + CopyFeedbackHarness(tagPrefix = "feedback") + } + + composeTestRule + .onNodeWithTag("feedback_status") + .assertIsDisplayed() + .assertTextEquals("Idle") + } + + @Test + fun settingTrueShowsFeedbackAndResetsAfter1500Ms() { + composeTestRule.mainClock.autoAdvance = false + + composeTestRule.setContent { + CopyFeedbackHarness(tagPrefix = "feedback") + } + + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle") + + composeTestRule.onNodeWithTag("feedback_trigger").performClick() + composeTestRule.mainClock.advanceTimeByFrame() + + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied") + + // Advance 1400ms - should still be true (100ms before 1500ms timeout) + composeTestRule.mainClock.advanceTimeBy(1400L) + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied") + + // Advance another 100ms to hit exactly 1500ms + composeTestRule.mainClock.advanceTimeBy(100L) + composeTestRule.mainClock.advanceTimeByFrame() + + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle") + } + + @Test + fun settingTrueAgainWhileAlreadyTrueDoesNotRestartActiveTimer() { + composeTestRule.mainClock.autoAdvance = false + + composeTestRule.setContent { + CopyFeedbackHarness(tagPrefix = "feedback") + } + + // Trigger at t = 0ms + composeTestRule.onNodeWithTag("feedback_trigger").performClick() + composeTestRule.mainClock.advanceTimeByFrame() + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied") + + // Advance to t = 1000ms + composeTestRule.mainClock.advanceTimeBy(1000L) + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied") + + // Re-trigger setting true at t = 1000ms while already true + composeTestRule.onNodeWithTag("feedback_trigger").performClick() + composeTestRule.mainClock.advanceTimeByFrame() + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied") + + // If the active timer is NOT restarted, it resets at t = 1500ms (500ms after the second click) + composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.mainClock.advanceTimeByFrame() + + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle") + } + + @Test + fun separateComposedHookInstancesAreIndependent() { + composeTestRule.mainClock.autoAdvance = false + + composeTestRule.setContent { + Column { + CopyFeedbackHarness(tagPrefix = "instance_a") + CopyFeedbackHarness(tagPrefix = "instance_b") + } + } + + composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle") + composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle") + + // Trigger only instance A + composeTestRule.onNodeWithTag("instance_a_trigger").performClick() + composeTestRule.mainClock.advanceTimeByFrame() + + // Instance A should be active, instance B should remain idle + composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Copied") + composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle") + + // Advance 1000ms + composeTestRule.mainClock.advanceTimeBy(1000L) + + // Trigger instance B at t = 1000ms + composeTestRule.onNodeWithTag("instance_b_trigger").performClick() + composeTestRule.mainClock.advanceTimeByFrame() + + // Both should be Copied now + composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Copied") + composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Copied") + + // Advance 500ms to t = 1500ms: Instance A should expire, Instance B should still have 1000ms remaining + composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.mainClock.advanceTimeByFrame() + + composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle") + composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Copied") + + // Advance remaining 1000ms for Instance B + composeTestRule.mainClock.advanceTimeBy(1000L) + composeTestRule.mainClock.advanceTimeByFrame() + + composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle") + composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle") + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt deleted file mode 100644 index 106360c2..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ /dev/null @@ -1,696 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -import android.content.ClipData -import android.text.format.DateFormat -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.InsertDriveFile -import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Psychology -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -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.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -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.graphics.Color -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.ClipEntry -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import com.m57.hermescontrol.R -import com.m57.hermescontrol.data.model.Attachment -import com.m57.hermescontrol.theme.DarkOnSurface -import com.m57.hermescontrol.theme.HermesStatusColors -import com.m57.hermescontrol.theme.LightOnSurface -import com.m57.hermescontrol.theme.LocalHermesStatusColors -import com.m57.hermescontrol.theme.onColorFor -import com.m57.hermescontrol.util.BidiUtils -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter - -/** - * The user-message bubble — the universal anchor in the full-bleed chat - * renderer (issue #866). Agent prose renders full-bleed; user messages keep - * this bubble so the conversation stays scannable. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun ChatBubble( - message: ChatMessage, - searchQuery: String = "", - isCurrentMatch: Boolean = false, - onOpenAttachment: (Attachment) -> Unit = {}, - onSaveAttachment: (Attachment) -> Unit = {}, - savingAttachmentPath: String? = null, - openingAttachmentPath: String? = null, - canSaveAttachment: Boolean = true, - onImageClick: (ImageViewerModel) -> Unit = {}, - messageStatsEnabled: Boolean = false, - showUserMessageTokens: Boolean = true, - modifier: Modifier = Modifier, -) { - val screenWidth = LocalConfiguration.current.screenWidthDp.dp - val maxBubbleWidth = screenWidth * 0.80f - - AnimatedVisibility( - visible = true, - enter = - fadeIn() + - expandVertically( - animationSpec = spring(stiffness = Spring.StiffnessMediumLow), - ), - ) { - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - var copied by remember { mutableStateOf(false) } - - // Copy feedback: briefly show ✓ then revert - LaunchedEffect(copied) { - if (copied) { - delay(1500) - copied = false - } - } - - val statusColors = LocalHermesStatusColors.current - - val highlightedText = - remember(message.content, searchQuery, isCurrentMatch, statusColors) { - if (searchQuery.isNotBlank()) { - buildHighlightedString( - message.content, - searchQuery, - isCurrentMatch, - statusColors, - ) - } else { - AnnotatedString(message.content) - } - } - Box( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp), - contentAlignment = Alignment.CenterEnd, - ) { - val primary = MaterialTheme.colorScheme.primary - val userBubbleTextColor = - if (primary.luminance() > 0.5f) { - if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) { - MaterialTheme.colorScheme.onPrimary - } else { - LightOnSurface - } - } else { - if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) { - MaterialTheme.colorScheme.onPrimary - } else { - DarkOnSurface - } - } - Box { - Surface( - modifier = - Modifier - .widthIn(max = maxBubbleWidth) - .clip( - RoundedCornerShape( - topStart = 16.dp, - topEnd = 16.dp, - bottomStart = 16.dp, - bottomEnd = 4.dp, - ), - ).background(color = primary) - .testTag("chat_bubble_user"), - color = Color.Transparent, - tonalElevation = 0.dp, - ) { - val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) } - val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current - CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) { - Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { - SelectionContainer { - Text( - text = highlightedText, - color = userBubbleTextColor, - style = - MaterialTheme.typography.bodyMedium.copy( - textDirection = - if (isRtl) { - TextDirection.Rtl - } else { - TextDirection.Ltr - }, - ), - ) - } - // Render inline attachments - if (!message.attachments.isNullOrEmpty()) { - Spacer(modifier = Modifier.height(6.dp)) - message.attachments.forEach { attachment -> - InlineAttachment( - attachment = attachment, - textColor = userBubbleTextColor, - onOpen = { onOpenAttachment(it) }, - onSave = { onSaveAttachment(it) }, - savingPath = savingAttachmentPath, - openingPath = openingAttachmentPath, - canSave = canSaveAttachment, - onImageClick = onImageClick, - ) - Spacer(modifier = Modifier.height(4.dp)) - } - } - if (!message.isStreaming) { - FlowRow( - modifier = - Modifier - .align(Alignment.End) - .padding(top = 4.dp), - itemVerticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry( - ClipEntry(ClipData.newPlainText(null, message.content)), - ) - } - copied = true - }, - modifier = Modifier.size(20.dp), - ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = stringResource(R.string.content_desc_copy), - modifier = Modifier.size(12.dp), - tint = userBubbleTextColor.copy(alpha = 0.7f), - ) - } - Spacer(modifier = Modifier.width(2.dp)) - Text( - text = - formatTimestamp( - message.timestamp, - DateFormat.is24HourFormat(LocalContext.current), - ), - color = userBubbleTextColor.copy(alpha = 0.6f), - style = MaterialTheme.typography.labelSmall, - ) - if (messageStatsEnabled && showUserMessageTokens && - message.tokenCount != null && message.tokenCount > 0 - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "•", - style = MaterialTheme.typography.labelSmall, - color = userBubbleTextColor.copy(alpha = 0.4f), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = - stringResource( - R.string.chat_msg_tokens, - TokenEstimator.formatTokenCount(message.tokenCount), - ), - color = userBubbleTextColor.copy(alpha = 0.7f), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.testTag("bubble_token_count"), - ) - } - } - } - } - } - } - } - } - } - } -} - -@Composable -private fun SelfImprovementReviewCard( - content: String, - modifier: Modifier = Modifier, -) { - val cleanText = - content - .removePrefix("💾") - .replace(Regex("^\\s*Self-improvement review:\\s*", RegexOption.IGNORE_CASE), "") - .trim() - val isSkill = - cleanText.contains("skill", ignoreCase = true) || - cleanText.contains("SKILL.md", ignoreCase = true) - val icon: ImageVector = if (isSkill) Icons.Filled.Build else Icons.Filled.Psychology - val title = - if (isSkill) { - "Self-Improvement Review • Skill Patched" - } else { - "Self-Improvement Review • Memory Updated" - } - - Card( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp) - .testTag("self_improvement_review_card"), - shape = RoundedCornerShape(10.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text( - text = title, - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary, - ) - } - Spacer(Modifier.height(4.dp)) - Text( - text = cleanText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -internal fun SystemBubble( - message: ChatMessage, - onRespondApproval: (String) -> Unit = {}, - modifier: Modifier = Modifier, -) { - if (message.content.contains("Self-improvement review:", ignoreCase = true)) { - SelfImprovementReviewCard(content = message.content, modifier = modifier) - return - } - - val approvalInfo = message.approvalInfo - var confirmAlways by remember(message.id) { mutableStateOf(false) } - - Column( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = message.content, - style = - MaterialTheme.typography.bodySmall.copy( - fontStyle = FontStyle.Italic, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - - // Approval action buttons — dynamic from backend `choices` - // (desktop `approval.tsx` parity: once/session/always/deny, - // smart-denied → once/deny only, allowPermanent=false hides Always). - if (approvalInfo != null) { - Spacer(Modifier.height(8.dp)) - val rawChoices = approvalInfo.choices ?: listOf("once", "deny") - val visibleChoices = - rawChoices.filter { choice -> - when (choice) { - "always" -> approvalInfo.allowPermanent != false - else -> true - } - } - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - for (choice in visibleChoices) { - when (choice) { - "deny" -> { - FilledTonalButton( - onClick = { onRespondApproval("deny") }, - modifier = - Modifier - .height(36.dp) - .testTag("deny_button"), - colors = - ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text("Deny") - } - } - - "session" -> { - FilledTonalButton( - onClick = { onRespondApproval("session") }, - modifier = - Modifier - .height(36.dp) - .testTag("session_button"), - colors = - ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - ), - ) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text("Allow session") - } - } - - "always" -> { - FilledTonalButton( - onClick = { confirmAlways = true }, - modifier = - Modifier - .height(36.dp) - .testTag("always_button"), - colors = - ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - ), - ) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text("Always allow") - } - } - - else -> { - // "once" (+ legacy "approve" → normalized to once in VM) - FilledTonalButton( - onClick = { onRespondApproval(choice) }, - modifier = - Modifier - .height(36.dp) - .testTag("approve_button"), - colors = - ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ), - ) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text(if (choice == "approve") "Approve" else "Run") - } - } - } - } - } - // "Always allow" persists the pattern permanently — confirm first - // (desktop confirm-modal parity). - if (confirmAlways) { - AlertDialog( - onDismissRequest = { confirmAlways = false }, - title = { Text("Always allow this command?") }, - text = { - Text( - "This persists the pattern permanently so future " + - "matching commands run without asking.", - ) - }, - confirmButton = { - TextButton( - onClick = { - confirmAlways = false - onRespondApproval("always") - }, - ) { - Text("Always allow") - } - }, - dismissButton = { - TextButton(onClick = { confirmAlways = false }) { - Text("Cancel") - } - }, - ) - } - } - } -} - -internal fun formatTimestamp( - timestamp: Long, - is24Hour: Boolean, -): String { - val pattern = if (is24Hour) "HH:mm" else "h:mm a" - return DateTimeFormatter - .ofPattern(pattern) - .withZone(ZoneId.systemDefault()) - .format(Instant.ofEpochMilli(timestamp)) -} - -/** - * Build an AnnotatedString with search matches highlighted. - */ -private fun buildHighlightedString( - text: String, - query: String, - isCurrentMatch: Boolean = false, - statusColors: HermesStatusColors, -): AnnotatedString { - val (highlightColor, highlightText) = - if (isCurrentMatch) { - statusColors.warning to statusColors.onWarning - } else { - statusColors.warningContainer to onColorFor(statusColors.warningContainer) - } - return buildAnnotatedString { - var i = 0 - while (i < text.length) { - val matchEnd = text.indexOf(query, i, ignoreCase = true) - if (matchEnd == -1) { - // No more matches — append the rest - append(text.substring(i)) - i = text.length - } else { - // Append text before the match - if (matchEnd > i) { - append(text.substring(i, matchEnd)) - } - // Append the match highlighted - withStyle(SpanStyle(background = highlightColor, color = highlightText)) { - append(text.substring(matchEnd, matchEnd + query.length)) - } - i = matchEnd + query.length - } - } - } -} - -/** - * Renders an attachment inline inside a chat bubble. - * Images are displayed as thumbnails; other files show a compact card. - */ -@Composable -internal fun InlineAttachment( - attachment: Attachment, - textColor: Color, - onOpen: (Attachment) -> Unit, - onSave: (Attachment) -> Unit, - savingPath: String?, - openingPath: String?, - canSave: Boolean, - onImageClick: (ImageViewerModel) -> Unit, -) { - val attachmentPath = attachment.gatewayUrl?.let(::gatewayPathFromUrl) ?: attachment.name - val isSaving = savingPath != null && savingPath == attachmentPath - val isOpening = openingPath != null && openingPath == attachmentPath - val clickable = Modifier.clickable { onOpen(attachment) } - if (attachment.isVideo || attachment.isAudio) { - var showMediaDialog by remember(attachment.uri) { mutableStateOf(false) } - com.m57.hermescontrol.ui.chat.components.InlineMediaPlayer( - uri = attachment.gatewayUrl ?: attachment.uri, - title = attachment.name, - mimeType = attachment.mimeType, - onFullScreenClick = { showMediaDialog = true }, - ) - if (showMediaDialog) { - com.m57.hermescontrol.ui.chat.components.MediaViewerDialog( - mediaUri = attachment.gatewayUrl ?: attachment.uri, - title = attachment.name, - mimeType = attachment.mimeType, - onDismissRequest = { showMediaDialog = false }, - ) - } - } else if (attachment.isImage) { - // Image / GIF attachment — show thumbnail with GIF badge & tap-to-play animation. - com.m57.hermescontrol.ui.chat.components.GifImageThumbnail( - model = attachment.uri, - contentDescription = attachment.name, - isGif = attachment.isGif, - onClick = { - onImageClick( - ImageViewerModel( - model = attachment.uri, - name = attachment.name, - mimeType = if (attachment.isGif) "image/gif" else attachment.mimeType, - ), - ) - }, - ) - } else { - // Non-image file — native save picker on the trailing action. - Surface( - shape = RoundedCornerShape(12.dp), - color = textColor.copy(alpha = 0.08f), - border = BorderStroke(1.dp, textColor.copy(alpha = 0.16f)), - modifier = clickable, - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.primaryContainer, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, - contentDescription = null, - modifier = Modifier.padding(9.dp).size(20.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } - Spacer(modifier = Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = attachment.name, - color = textColor, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = attachment.formattedSize, - color = textColor.copy(alpha = 0.6f), - style = MaterialTheme.typography.labelSmall, - ) - } - if (isOpening) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.width(10.dp)) - } - if (attachment.gatewayUrl != null) { - IconButton( - onClick = { onSave(attachment) }, - enabled = canSave, - ) { - if (isSaving) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - ) - } else { - Icon( - imageVector = Icons.Default.Download, - contentDescription = stringResource(R.string.chat_save_attachment, attachment.name), - tint = MaterialTheme.colorScheme.primary, - ) - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ExpandedToolContent.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ExpandedToolContent.kt new file mode 100644 index 00000000..bf41d69f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ExpandedToolContent.kt @@ -0,0 +1,237 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.ui.chat.components.DiffViewCard +import com.m57.hermescontrol.ui.chat.components.FileViewCard +import com.m57.hermescontrol.ui.chat.tool.ToolView + +/** + * Renders the engine's [ToolView] — the expanded body of a tool card. + * + * Order: error line → terminal streams ($ command / stdout / stderr / exit + * code) → file diff → search hits → plain detail → duration footer. + */ +@Composable +internal fun ExpandedToolContent( + view: ToolView, + contentColor: Color, + statusColors: HermesStatusColors, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + val isTerminal = + view.stdout != null || + view.stderr != null || + view.exitCode != null || + view.terminalCommand != null + + // ── Error line ── + view.error?.let { + Text( + text = stringResource(R.string.chat_tool_execution_error, it), + style = + MaterialTheme.typography.bodySmall.copy( + color = statusColors.error, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + + // ── Terminal: $ command + streams + exit code ── + if (isTerminal) { + view.terminalCommand?.takeIf { it.isNotEmpty() }?.let { command -> + Text( + text = "$ $command", + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.7f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + view.stdout?.let { + Text( + text = it, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .verticalScroll(rememberScrollState()), + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.9f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + view.stderr?.let { + Text( + text = it, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .verticalScroll(rememberScrollState()), + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.6f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + view.exitCode?.let { code -> + if (code != 0) { + Text( + text = stringResource(R.string.chat_tool_exit_code, code), + style = + MaterialTheme.typography.labelSmall.copy( + color = statusColors.warning, + fontWeight = FontWeight.Medium, + ), + ) + } + } + } else { + // ── Generated/resolved image preview ── + view.imageUrl?.let { url -> + AsyncImage( + model = url, + contentDescription = view.title, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .clip(RoundedCornerShape(8.dp)), + ) + } + + // ── File diff ── + if (view.inlineDiff != null) { + DiffViewCard( + diffText = view.inlineDiff, + filePath = view.diffPath, + ) + } else if (view.fileContent != null) { + FileViewCard( + content = view.fileContent, + filePath = view.filePath, + ) + } + + // ── Search hits ── + if (!view.searchHits.isNullOrEmpty()) { + view.detailLabel?.let { label -> + Text( + text = label, + style = + MaterialTheme.typography.labelSmall.copy( + color = contentColor.copy(alpha = 0.6f), + fontWeight = FontWeight.Bold, + ), + ) + } + view.searchHits.forEach { hit -> + Column(modifier = Modifier.padding(top = 2.dp)) { + if (hit.title.isNotEmpty()) { + Text( + text = hit.title, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.9f), + fontWeight = FontWeight.Medium, + ), + ) + } + if (hit.snippet.isNotEmpty()) { + Text( + text = hit.snippet, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.6f), + fontSize = 11.sp, + ), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + if (hit.url.isNotEmpty()) { + Text( + text = hit.url, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.7f), + fontSize = 11.sp, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + + // ── Plain detail body ── + if (view.detail.isNotBlank() && view.inlineDiff == null && view.fileContent == null) { + Text( + text = view.detail, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .verticalScroll(rememberScrollState()), + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.9f), + fontSize = 12.sp, + ), + ) + } + } + + view.outputCut?.let { omitted -> + Text( + text = stringResource(R.string.chat_tool_output_omitted, omitted), + style = MaterialTheme.typography.labelSmall.copy(color = statusColors.warning), + ) + } + + // ── Duration footer ── + view.durationLabel?.let { + Text( + text = stringResource(R.string.tool_duration, it), + style = + MaterialTheme.typography.labelSmall.copy( + color = contentColor.copy(alpha = 0.5f), + ), + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/InlineAttachment.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/InlineAttachment.kt new file mode 100644 index 00000000..cba72589 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/InlineAttachment.kt @@ -0,0 +1,156 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +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 com.m57.hermescontrol.data.model.Attachment + +/** + * Renders an attachment inline inside a chat bubble. + * Images are displayed as thumbnails; other files show a compact card. + */ +@Composable +internal fun InlineAttachment( + attachment: Attachment, + textColor: Color, + onOpen: (Attachment) -> Unit, + onSave: (Attachment) -> Unit, + savingPath: String?, + openingPath: String?, + canSave: Boolean, + onImageClick: (ImageViewerModel) -> Unit, +) { + val attachmentPath = attachment.gatewayUrl?.let(::gatewayPathFromUrl) ?: attachment.name + val isSaving = savingPath != null && savingPath == attachmentPath + val isOpening = openingPath != null && openingPath == attachmentPath + val clickable = Modifier.clickable { onOpen(attachment) } + if (attachment.isVideo || attachment.isAudio) { + var showMediaDialog by remember(attachment.uri) { mutableStateOf(false) } + com.m57.hermescontrol.ui.chat.components.InlineMediaPlayer( + uri = attachment.gatewayUrl ?: attachment.uri, + title = attachment.name, + mimeType = attachment.mimeType, + onFullScreenClick = { showMediaDialog = true }, + ) + if (showMediaDialog) { + com.m57.hermescontrol.ui.chat.components.MediaViewerDialog( + mediaUri = attachment.gatewayUrl ?: attachment.uri, + title = attachment.name, + mimeType = attachment.mimeType, + onDismissRequest = { showMediaDialog = false }, + ) + } + } else if (attachment.isImage) { + // Image / GIF attachment — show thumbnail with GIF badge & tap-to-play animation. + com.m57.hermescontrol.ui.chat.components.GifImageThumbnail( + model = attachment.uri, + contentDescription = attachment.name, + isGif = attachment.isGif, + onClick = { + onImageClick( + ImageViewerModel( + model = attachment.uri, + name = attachment.name, + mimeType = if (attachment.isGif) "image/gif" else attachment.mimeType, + ), + ) + }, + ) + } else { + // Non-image file — native save picker on the trailing action. + Surface( + shape = RoundedCornerShape(12.dp), + color = textColor.copy(alpha = 0.08f), + border = BorderStroke(1.dp, textColor.copy(alpha = 0.16f)), + modifier = clickable, + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, + contentDescription = null, + modifier = Modifier.padding(9.dp).size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = attachment.name, + color = textColor, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = attachment.formattedSize, + color = textColor.copy(alpha = 0.6f), + style = MaterialTheme.typography.labelSmall, + ) + } + if (isOpening) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(10.dp)) + } + if (attachment.gatewayUrl != null) { + IconButton( + onClick = { onSave(attachment) }, + enabled = canSave, + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.Default.Download, + contentDescription = stringResource(R.string.chat_save_attachment, attachment.name), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MessageTimestamp.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MessageTimestamp.kt new file mode 100644 index 00000000..699d2899 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MessageTimestamp.kt @@ -0,0 +1,16 @@ +package com.m57.hermescontrol.ui.chat + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +internal fun formatTimestamp( + timestamp: Long, + is24Hour: Boolean, +): String { + val pattern = if (is24Hour) "HH:mm" else "h:mm a" + return DateTimeFormatter + .ofPattern(pattern) + .withZone(ZoneId.systemDefault()) + .format(Instant.ofEpochMilli(timestamp)) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SystemBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SystemBubble.kt new file mode 100644 index 00000000..4e7436dc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SystemBubble.kt @@ -0,0 +1,272 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +private fun SelfImprovementReviewCard( + content: String, + modifier: Modifier = Modifier, +) { + val cleanText = + content + .removePrefix("💾") + .replace(Regex("^\\s*Self-improvement review:\\s*", RegexOption.IGNORE_CASE), "") + .trim() + val isSkill = + cleanText.contains("skill", ignoreCase = true) || + cleanText.contains("SKILL.md", ignoreCase = true) + val icon: ImageVector = if (isSkill) Icons.Filled.Build else Icons.Filled.Psychology + val title = + if (isSkill) { + "Self-Improvement Review • Skill Patched" + } else { + "Self-Improvement Review • Memory Updated" + } + + Card( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + .testTag("self_improvement_review_card"), + shape = RoundedCornerShape(10.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = cleanText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun SystemBubble( + message: ChatMessage, + onRespondApproval: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) { + if (message.content.contains("Self-improvement review:", ignoreCase = true)) { + SelfImprovementReviewCard(content = message.content, modifier = modifier) + return + } + + val approvalInfo = message.approvalInfo + var confirmAlways by remember(message.id) { mutableStateOf(false) } + + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = message.content, + style = + MaterialTheme.typography.bodySmall.copy( + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + + // Approval action buttons — dynamic from backend `choices` + // (desktop `approval.tsx` parity: once/session/always/deny, + // smart-denied → once/deny only, allowPermanent=false hides Always). + if (approvalInfo != null) { + Spacer(Modifier.height(8.dp)) + val rawChoices = approvalInfo.choices ?: listOf("once", "deny") + val visibleChoices = + rawChoices.filter { choice -> + when (choice) { + "always" -> approvalInfo.allowPermanent != false + else -> true + } + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (choice in visibleChoices) { + when (choice) { + "deny" -> { + FilledTonalButton( + onClick = { onRespondApproval("deny") }, + modifier = + Modifier + .height(36.dp) + .testTag("deny_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Deny") + } + } + + "session" -> { + FilledTonalButton( + onClick = { onRespondApproval("session") }, + modifier = + Modifier + .height(36.dp) + .testTag("session_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Allow session") + } + } + + "always" -> { + FilledTonalButton( + onClick = { confirmAlways = true }, + modifier = + Modifier + .height(36.dp) + .testTag("always_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Always allow") + } + } + + else -> { + // "once" (+ legacy "approve" → normalized to once in VM) + FilledTonalButton( + onClick = { onRespondApproval(choice) }, + modifier = + Modifier + .height(36.dp) + .testTag("approve_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(if (choice == "approve") "Approve" else "Run") + } + } + } + } + } + // "Always allow" persists the pattern permanently — confirm first + // (desktop confirm-modal parity). + if (confirmAlways) { + AlertDialog( + onDismissRequest = { confirmAlways = false }, + title = { Text("Always allow this command?") }, + text = { + Text( + "This persists the pattern permanently so future " + + "matching commands run without asking.", + ) + }, + confirmButton = { + TextButton( + onClick = { + confirmAlways = false + onRespondApproval("always") + }, + ) { + Text("Always allow") + } + }, + dismissButton = { + TextButton(onClick = { confirmAlways = false }) { + Text("Cancel") + } + }, + ) + } + } + } +} 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 5aa93e35..54946d42 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 @@ -45,7 +45,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -60,7 +59,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration @@ -69,23 +67,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.m57.hermescontrol.R -import com.m57.hermescontrol.theme.CodeTerminalBg -import com.m57.hermescontrol.theme.CodeTerminalBorder -import com.m57.hermescontrol.theme.CodeTerminalMuted -import com.m57.hermescontrol.theme.CodeTerminalText import com.m57.hermescontrol.theme.HermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors -import com.m57.hermescontrol.ui.chat.components.CodeTerminalCard import com.m57.hermescontrol.ui.chat.components.DiffViewCard import com.m57.hermescontrol.ui.chat.components.FileViewCard -import com.m57.hermescontrol.ui.chat.components.highlightSyntax -import com.m57.hermescontrol.ui.chat.tool.ToolJson import com.m57.hermescontrol.ui.chat.tool.ToolView import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext /** * Composes the collapsed summary lines for a tool card. @@ -179,7 +168,7 @@ internal fun ToolBubble( .padding(horizontal = 10.dp, vertical = 6.dp), ) { // ── Header row: icon + tool name ── - HeaderRow(message, config, contentColor, statusColors, view?.serverDisplayName) + ToolBubbleHeader(message, config, contentColor, statusColors, view?.serverDisplayName) // ── Tool progress preview (tool.progress) ── if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) { @@ -314,394 +303,6 @@ internal fun ToolBubble( } } -/** - * Renders the engine's [ToolView] — the expanded body of a tool card. - * - * Order: error line → terminal streams ($ command / stdout / stderr / exit - * code) → file diff → search hits → plain detail → duration footer. - */ -@Composable -private fun ExpandedToolContent( - view: ToolView, - contentColor: Color, - statusColors: HermesStatusColors, -) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - val isTerminal = - view.stdout != null || - view.stderr != null || - view.exitCode != null || - view.terminalCommand != null - - // ── Error line ── - view.error?.let { - Text( - text = stringResource(R.string.chat_tool_execution_error, it), - style = - MaterialTheme.typography.bodySmall.copy( - color = statusColors.error, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - } - - // ── Terminal: $ command + streams + exit code ── - if (isTerminal) { - view.terminalCommand?.takeIf { it.isNotEmpty() }?.let { command -> - Text( - text = "$ $command", - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.7f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - } - view.stdout?.let { - Text( - text = it, - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 240.dp) - .verticalScroll(rememberScrollState()), - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.9f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - } - view.stderr?.let { - Text( - text = it, - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 240.dp) - .verticalScroll(rememberScrollState()), - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.6f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - } - view.exitCode?.let { code -> - if (code != 0) { - Text( - text = stringResource(R.string.chat_tool_exit_code, code), - style = - MaterialTheme.typography.labelSmall.copy( - color = statusColors.warning, - fontWeight = FontWeight.Medium, - ), - ) - } - } - } else { - // ── Generated/resolved image preview ── - view.imageUrl?.let { url -> - AsyncImage( - model = url, - contentDescription = view.title, - contentScale = ContentScale.Crop, - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 200.dp) - .clip(RoundedCornerShape(8.dp)), - ) - } - - // ── File diff ── - if (view.inlineDiff != null) { - DiffViewCard( - diffText = view.inlineDiff, - filePath = view.diffPath, - ) - } else if (view.fileContent != null) { - FileViewCard( - content = view.fileContent, - filePath = view.filePath, - ) - } - - // ── Search hits ── - if (!view.searchHits.isNullOrEmpty()) { - view.detailLabel?.let { label -> - Text( - text = label, - style = - MaterialTheme.typography.labelSmall.copy( - color = contentColor.copy(alpha = 0.6f), - fontWeight = FontWeight.Bold, - ), - ) - } - view.searchHits.forEach { hit -> - Column(modifier = Modifier.padding(top = 2.dp)) { - if (hit.title.isNotEmpty()) { - Text( - text = hit.title, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.9f), - fontWeight = FontWeight.Medium, - ), - ) - } - if (hit.snippet.isNotEmpty()) { - Text( - text = hit.snippet, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.6f), - fontSize = 11.sp, - ), - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - if (hit.url.isNotEmpty()) { - Text( - text = hit.url, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.7f), - fontSize = 11.sp, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - } - - // ── Plain detail body ── - if (view.detail.isNotBlank() && view.inlineDiff == null && view.fileContent == null) { - Text( - text = view.detail, - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 240.dp) - .verticalScroll(rememberScrollState()), - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.9f), - fontSize = 12.sp, - ), - ) - } - } - - view.outputCut?.let { omitted -> - Text( - text = stringResource(R.string.chat_tool_output_omitted, omitted), - style = MaterialTheme.typography.labelSmall.copy(color = statusColors.warning), - ) - } - - // ── Duration footer ── - view.durationLabel?.let { - Text( - text = stringResource(R.string.tool_duration, it), - style = - MaterialTheme.typography.labelSmall.copy( - color = contentColor.copy(alpha = 0.5f), - ), - ) - } - } -} - -@Composable -private fun HeaderRow( - message: ChatMessage, - config: ToolDisplayConfig, - contentColor: Color, - statusColors: HermesStatusColors, - displayName: String? = null, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Status icon or spinner - if (message.isToolRunning) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.secondary, - ) - } else { - val icon = - when (message.toolStatus) { - ToolStatus.COMPLETED -> Icons.Filled.CheckCircle - ToolStatus.FAILED -> Icons.Filled.Error - else -> Icons.Filled.Build - } - val tint = - when (message.toolStatus) { - ToolStatus.COMPLETED -> statusColors.success - ToolStatus.FAILED -> statusColors.error - else -> contentColor.copy(alpha = 0.6f) - } - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = tint, - ) - } - - Text( - text = displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback), - style = - MaterialTheme.typography.labelMedium.copy( - color = contentColor, - fontFamily = FontFamily.Monospace, - ), - ) - } -} - -/** - * Security risk chip for [tool.output_risk] events. - * - * Shows a compact ⚠ badge when the backend flagged tool output as risky. - * Renders in the tool card between the header row and the summary line. - */ -@Composable -internal fun SecurityRiskChip( - riskData: ToolOutputRiskData, - contentColor: Color, - modifier: Modifier = Modifier, -) { - val statusColors = LocalHermesStatusColors.current - val (chipColor, label) = - when { - riskData.risk == "high" -> statusColors.error to "Risky output" - riskData.risk == "medium" -> statusColors.warning to "Caution" - else -> statusColors.warning to "Redacted" - } - - Row( - modifier = - modifier - .padding(top = 4.dp, start = 22.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = null, - tint = chipColor, - modifier = Modifier.size(14.dp), - ) - Text( - text = label, - style = - MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Medium, - ), - color = chipColor, - ) - if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) { - Text( - text = stringResource(R.string.tool_redacted), - style = MaterialTheme.typography.labelSmall, - color = chipColor.copy(alpha = 0.7f), - ) - } - } -} - -@Composable -private fun ToolRawJsonView( - rawContent: String, - modifier: Modifier = Modifier, -) { - var formatJson by remember { mutableStateOf(true) } - val clampedRaw = remember(rawContent) { ToolJson.clampForDisplay(rawContent) } - - // Full pretty text (for copy) + clamped copy (for display); null when formatting changes nothing. - val formatted by produceState?>(initialValue = null, key1 = rawContent) { - value = - withContext(Dispatchers.Default) { - val full = ToolJson.prettyPrintJson(rawContent) - if (full != rawContent) full to ToolJson.clampForDisplay(full) else null - } - } - - val isFormatDifferent = formatted != null - val displayText = formatted?.takeIf { formatJson }?.second ?: clampedRaw - val copyText = formatted?.takeIf { formatJson }?.first ?: rawContent - - val highlighted by produceState( - initialValue = remember(clampedRaw) { AnnotatedString(clampedRaw) }, - key1 = displayText, - ) { - value = - withContext(Dispatchers.Default) { - highlightSyntax(displayText) - } - } - - CodeTerminalCard( - textToCopy = copyText, - modifier = modifier, - testTag = "tool_raw_json", - title = "JSON", - copyContentDescription = stringResource(R.string.content_desc_copy), - headerActions = { - if (isFormatDifferent) { - Text( - text = - if (formatJson) { - stringResource(R.string.chat_tool_compact_json) - } else { - stringResource(R.string.chat_tool_format_json) - }, - style = - MaterialTheme.typography.labelSmall.copy( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - ), - modifier = - Modifier - .testTag("tool_json_format_toggle") - .clickable(role = Role.Button) { formatJson = !formatJson } - .padding(horizontal = 6.dp, vertical = 2.dp), - ) - } - }, - ) { - SelectionContainer { - Text( - text = highlighted, - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 280.dp) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 6.dp), - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = CodeTerminalText, - softWrap = true, - ) - } - } -} - @Composable private fun CopyButton( visible: Boolean, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt new file mode 100644 index 00000000..e5262dbc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt @@ -0,0 +1,128 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +@Composable +internal fun ToolBubbleHeader( + message: ChatMessage, + config: ToolDisplayConfig, + contentColor: Color, + statusColors: HermesStatusColors, + displayName: String? = null, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Status icon or spinner + if (message.isToolRunning) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.secondary, + ) + } else { + val icon = + when (message.toolStatus) { + ToolStatus.COMPLETED -> Icons.Filled.CheckCircle + ToolStatus.FAILED -> Icons.Filled.Error + else -> Icons.Filled.Build + } + val tint = + when (message.toolStatus) { + ToolStatus.COMPLETED -> statusColors.success + ToolStatus.FAILED -> statusColors.error + else -> contentColor.copy(alpha = 0.6f) + } + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = tint, + ) + } + + Text( + text = displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback), + style = + MaterialTheme.typography.labelMedium.copy( + color = contentColor, + fontFamily = FontFamily.Monospace, + ), + ) + } +} + +/** + * Security risk chip for [tool.output_risk] events. + * + * Shows a compact ⚠ badge when the backend flagged tool output as risky. + * Renders in the tool card between the header row and the summary line. + */ +@Composable +internal fun SecurityRiskChip( + riskData: ToolOutputRiskData, + contentColor: Color, + modifier: Modifier = Modifier, +) { + val statusColors = LocalHermesStatusColors.current + val (chipColor, label) = + when { + riskData.risk == "high" -> statusColors.error to "Risky output" + riskData.risk == "medium" -> statusColors.warning to "Caution" + else -> statusColors.warning to "Redacted" + } + + Row( + modifier = + modifier + .padding(top = 4.dp, start = 22.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = chipColor, + modifier = Modifier.size(14.dp), + ) + Text( + text = label, + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + ), + color = chipColor, + ) + if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) { + Text( + text = stringResource(R.string.tool_redacted), + style = MaterialTheme.typography.labelSmall, + color = chipColor.copy(alpha = 0.7f), + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolRawJsonView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolRawJsonView.kt new file mode 100644 index 00000000..b995cf13 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolRawJsonView.kt @@ -0,0 +1,111 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.CodeTerminalText +import com.m57.hermescontrol.ui.chat.components.CodeTerminalCard +import com.m57.hermescontrol.ui.chat.components.highlightSyntax +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +internal fun ToolRawJsonView( + rawContent: String, + modifier: Modifier = Modifier, +) { + var formatJson by remember { mutableStateOf(true) } + val clampedRaw = remember(rawContent) { ToolJson.clampForDisplay(rawContent) } + + // Full pretty text (for copy) + clamped copy (for display); null when formatting changes nothing. + val formatted by produceState?>(initialValue = null, key1 = rawContent) { + value = + withContext(Dispatchers.Default) { + val full = ToolJson.prettyPrintJson(rawContent) + if (full != rawContent) full to ToolJson.clampForDisplay(full) else null + } + } + + val isFormatDifferent = formatted != null + val displayText = formatted?.takeIf { formatJson }?.second ?: clampedRaw + val copyText = formatted?.takeIf { formatJson }?.first ?: rawContent + + val highlighted by produceState( + initialValue = remember(clampedRaw) { AnnotatedString(clampedRaw) }, + key1 = displayText, + ) { + value = + withContext(Dispatchers.Default) { + highlightSyntax(displayText) + } + } + + CodeTerminalCard( + textToCopy = copyText, + modifier = modifier, + testTag = "tool_raw_json", + title = "JSON", + copyContentDescription = stringResource(R.string.content_desc_copy), + headerActions = { + if (isFormatDifferent) { + Text( + text = + if (formatJson) { + stringResource(R.string.chat_tool_compact_json) + } else { + stringResource(R.string.chat_tool_format_json) + }, + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + modifier = + Modifier + .testTag("tool_json_format_toggle") + .clickable(role = Role.Button) { formatJson = !formatJson } + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + }, + ) { + SelectionContainer { + Text( + text = highlighted, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 280.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 6.dp), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = CodeTerminalText, + softWrap = true, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt new file mode 100644 index 00000000..33332774 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt @@ -0,0 +1,301 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.ClipData +import android.text.format.DateFormat +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.theme.DarkOnSurface +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.LightOnSurface +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.theme.onColorFor +import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback +import com.m57.hermescontrol.util.BidiUtils +import kotlinx.coroutines.launch + +/** + * The user-message bubble — the universal anchor in the full-bleed chat + * renderer (issue #866). Agent prose renders full-bleed; user messages keep + * this bubble so the conversation stays scannable. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun UserBubble( + message: ChatMessage, + searchQuery: String = "", + isCurrentMatch: Boolean = false, + onOpenAttachment: (Attachment) -> Unit = {}, + onSaveAttachment: (Attachment) -> Unit = {}, + savingAttachmentPath: String? = null, + openingAttachmentPath: String? = null, + canSaveAttachment: Boolean = true, + onImageClick: (ImageViewerModel) -> Unit = {}, + messageStatsEnabled: Boolean = false, + showUserMessageTokens: Boolean = true, + modifier: Modifier = Modifier, +) { + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val maxBubbleWidth = screenWidth * 0.80f + + AnimatedVisibility( + visible = true, + enter = + fadeIn() + + expandVertically( + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + ), + ) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + // Copy feedback: briefly show ✓ then revert + var copied by rememberCopyFeedback() + + val statusColors = LocalHermesStatusColors.current + + val highlightedText = + remember(message.content, searchQuery, isCurrentMatch, statusColors) { + if (searchQuery.isNotBlank()) { + buildHighlightedString( + message.content, + searchQuery, + isCurrentMatch, + statusColors, + ) + } else { + AnnotatedString(message.content) + } + } + Box( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + contentAlignment = Alignment.CenterEnd, + ) { + val primary = MaterialTheme.colorScheme.primary + val userBubbleTextColor = + if (primary.luminance() > 0.5f) { + if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) { + MaterialTheme.colorScheme.onPrimary + } else { + LightOnSurface + } + } else { + if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) { + MaterialTheme.colorScheme.onPrimary + } else { + DarkOnSurface + } + } + Box { + Surface( + modifier = + Modifier + .widthIn(max = maxBubbleWidth) + .clip( + RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + bottomStart = 16.dp, + bottomEnd = 4.dp, + ), + ).background(color = primary) + .testTag("chat_bubble_user"), + color = Color.Transparent, + tonalElevation = 0.dp, + ) { + val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) } + val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { + SelectionContainer { + Text( + text = highlightedText, + color = userBubbleTextColor, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = + if (isRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), + ) + } + // Render inline attachments + if (!message.attachments.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + message.attachments.forEach { attachment -> + InlineAttachment( + attachment = attachment, + textColor = userBubbleTextColor, + onOpen = { onOpenAttachment(it) }, + onSave = { onSaveAttachment(it) }, + savingPath = savingAttachmentPath, + openingPath = openingAttachmentPath, + canSave = canSaveAttachment, + onImageClick = onImageClick, + ) + Spacer(modifier = Modifier.height(4.dp)) + } + } + if (!message.isStreaming) { + FlowRow( + modifier = + Modifier + .align(Alignment.End) + .padding(top = 4.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText(null, message.content)), + ) + } + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(12.dp), + tint = userBubbleTextColor.copy(alpha = 0.7f), + ) + } + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = + formatTimestamp( + message.timestamp, + DateFormat.is24HourFormat(LocalContext.current), + ), + color = userBubbleTextColor.copy(alpha = 0.6f), + style = MaterialTheme.typography.labelSmall, + ) + if (messageStatsEnabled && showUserMessageTokens && + message.tokenCount != null && message.tokenCount > 0 + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = userBubbleTextColor.copy(alpha = 0.4f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tokens, + TokenEstimator.formatTokenCount(message.tokenCount), + ), + color = userBubbleTextColor.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.testTag("bubble_token_count"), + ) + } + } + } + } + } + } + } + } + } + } +} + +/** + * Build an AnnotatedString with search matches highlighted. + */ +private fun buildHighlightedString( + text: String, + query: String, + isCurrentMatch: Boolean = false, + statusColors: HermesStatusColors, +): AnnotatedString { + val (highlightColor, highlightText) = + if (isCurrentMatch) { + statusColors.warning to statusColors.onWarning + } else { + statusColors.warningContainer to onColorFor(statusColors.warningContainer) + } + return buildAnnotatedString { + var i = 0 + while (i < text.length) { + val matchEnd = text.indexOf(query, i, ignoreCase = true) + if (matchEnd == -1) { + // No more matches — append the rest + append(text.substring(i)) + i = text.length + } else { + // Append text before the match + if (matchEnd > i) { + append(text.substring(i, matchEnd)) + } + // Append the match highlighted + withStyle(SpanStyle(background = highlightColor, color = highlightText)) { + append(text.substring(matchEnd, matchEnd + query.length)) + } + i = matchEnd + query.length + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubble.kt new file mode 100644 index 00000000..d2c42b1b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubble.kt @@ -0,0 +1,301 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.ClarifyUi + +// ── ClarifyBubble ───────────────────────────────────────────────────────── + +/** + * Centered dashed-border bubble showing a clarify question (or batch of questions) with + * selectable option chips, multi-select toggles, open-ended input, and a dismiss button. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ClarifyBubble( + clarifyRequest: ClarifyUi, + onRespondSingle: (String) -> Unit, + onRespondBatch: (Map) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val questions = clarifyRequest.resolvedQuestions + val isBatch = questions.size > 1 + + var selectedChoicesByQid by remember(clarifyRequest) { + mutableStateOf>>(emptyMap()) + } + var customTextByQid by remember(clarifyRequest) { + mutableStateOf>(emptyMap()) + } + + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 4.dp) + .testTag("clarify_bubble"), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = + BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + ), + ) { + Column( + modifier = Modifier.padding(14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + questions.forEachIndexed { index, q -> + val lockedAnswer = clarifyRequest.lockedAnswers[q.qid] + if (index > 0) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + + if (isBatch) { + Text( + text = "${index + 1}. ${q.question}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) + } else { + Text( + text = q.question, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (lockedAnswer != null) { + Surface( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(8.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = "Answered: $lockedAnswer", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } else { + if (q.multiSelect) { + Spacer(Modifier.height(2.dp)) + Text( + text = "Select all that apply", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (q.choices.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + q.choices.forEach { choice -> + val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet() + val isSelected = selectedSet.contains(choice) + FilterChip( + selected = isSelected, + onClick = { + if (q.multiSelect) { + val updated = if (isSelected) selectedSet - choice else selectedSet + choice + selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) + } else { + if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) { + // Fast 1-tap respond for lone single-select question when no custom text is entered + onRespondSingle(choice) + } else { + val updated = if (isSelected) emptySet() else setOf(choice) + selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) + } + } + }, + label = { Text(choice) }, + leadingIcon = + if (isSelected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } else { + null + }, + ) + } + } + } + + Spacer(Modifier.height(8.dp)) + val typed = customTextByQid[q.qid].orEmpty() + OutlinedTextField( + value = typed, + onValueChange = { newText -> + customTextByQid = customTextByQid + (q.qid to newText) + }, + label = { + Text( + if (q.choices.isEmpty()) { + stringResource(R.string.message_your_response) + } else { + "Other (optional)" + }, + ) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + } + + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + ) { + val hasAnyInput = + questions.any { q -> + (selectedChoicesByQid[q.qid]?.isNotEmpty() == true) || + (!customTextByQid[q.qid].isNullOrBlank()) + } + + FilledTonalButton( + onClick = { + if (isBatch) { + val answers = + questions.associate { q -> + val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty() + val custom = customTextByQid[q.qid]?.trim().orEmpty() + val finalAns = + when { + selectedList.isNotEmpty() && custom.isNotEmpty() -> { + (selectedList + custom).joinToString(", ") + } + + selectedList.isNotEmpty() -> { + selectedList.joinToString(", ") + } + + else -> { + custom + } + } + q.qid to finalAns + } + onRespondBatch(answers) + } else { + val q = questions.first() + val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty() + val custom = customTextByQid[q.qid]?.trim().orEmpty() + val finalAns = + when { + selectedList.isNotEmpty() && custom.isNotEmpty() -> { + (selectedList + custom).joinToString(", ") + } + + selectedList.isNotEmpty() -> { + selectedList.joinToString(", ") + } + + else -> { + custom + } + } + if (finalAns.isNotBlank()) { + onRespondSingle(finalAns) + } + } + }, + enabled = hasAnyInput, + ) { + Text(if (isBatch) "Submit All" else "Send") + } + TextButton(onClick = onDismiss) { + Text("Dismiss") + } + } + } + } +} + +/** + * Backward-compatible overload for legacy callers and unit tests. + */ +@Composable +fun ClarifyBubble( + text: String, + options: List, + onOptionSelected: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ClarifyBubble( + clarifyRequest = ClarifyUi(text = text, options = options), + onRespondSingle = onOptionSelected, + onRespondBatch = { answers -> + answers.values.firstOrNull()?.let(onOptionSelected) + }, + onDismiss = onDismiss, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt new file mode 100644 index 00000000..38f6ac92 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt @@ -0,0 +1,61 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.theme.CodeTerminalText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Terminal-style card for a fenced code block with syntax highlighting, + * a language badge, and a copy button. + */ +@Composable +fun CodeBlockCard( + code: String, + language: String?, + onCopy: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val highlighted by produceState( + initialValue = remember(code) { AnnotatedString(code) }, + key1 = code, + ) { + value = + withContext(Dispatchers.Default) { + highlightSyntax(code) + } + } + + CodeTerminalCard( + textToCopy = code, + modifier = modifier, + testTag = "code_block", + title = language?.takeIf { it.isNotBlank() }?.uppercase(), + onCopy = onCopy, + copyContentDescription = "Copy code", + ) { + Text( + text = highlighted, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + color = CodeTerminalText, + softWrap = false, + modifier = + Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeSyntaxHighlighter.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeSyntaxHighlighter.kt new file mode 100644 index 00000000..34721d6d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeSyntaxHighlighter.kt @@ -0,0 +1,103 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import com.m57.hermescontrol.theme.CodeComment +import com.m57.hermescontrol.theme.CodeKeyword +import com.m57.hermescontrol.theme.CodeNumber +import com.m57.hermescontrol.theme.CodePunctuation +import com.m57.hermescontrol.theme.CodeString + +// Hoisted token patterns for syntax highlighting — compiled once. +private val HIGHLIGHT_TOKENS = + listOf( + TokenPattern(Regex("""//[^\n]*"""), CodeComment), + TokenPattern(Regex("""/\*[\s\S]*?\*/"""), CodeComment), + TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*"(?=\s*:)"""), CodeKeyword), + TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*""""), CodeString), + TokenPattern(Regex("""'[^'\\]*(\\.[^'\\]*)*'"""), CodeString), + TokenPattern(Regex("""`[^`\\]*(\\.[^`\\]*)*`"""), CodeString), + TokenPattern(Regex("""\b0[xX][0-9a-fA-F]+\b"""), CodeNumber), + TokenPattern(Regex("""\b\d+\.?\d*(?:[eE][+-]?\d+)?\b"""), CodeNumber), + TokenPattern( + Regex( + """\b(?:val|var|fun|class|object|interface|enum|data|sealed|open|abstract|""" + + """override|private|protected|public|internal|import|package|""" + + """if|else|when|for|while|do|return|throw|try|catch|finally|""" + + """true|false|null|this|super|is|in|as|typealias|companion|""" + + """init|constructor|by|get|set|field|value|suspend|inline|""" + + """infix|operator|tailrec|external|annotation)\b""", + ), + CodeKeyword, + ), + TokenPattern(Regex("""[{}()\[\];:.]"""), CodePunctuation), + ) + +/** + * Builds an [AnnotatedString] from [code] with syntax highlighting colours + * applied via token regexes. Covers keywords, strings, comments, numbers, + * and punctuation — everything else remains the default light-grey. + */ +internal fun highlightSyntax(code: String): AnnotatedString = + buildAnnotatedString { + val tokens = HIGHLIGHT_TOKENS + var lastIndex = 0 + val matches = mutableListOf>() + + for (pattern in tokens) { + for (match in pattern.regex.findAll(code)) { + matches.add(match.range to pattern.color) + } + } + + matches.sortBy { it.first.first } + // Resolve overlaps: later-in-text wins for same-pos, else first-match + val resolved = mutableListOf>() + for (m in matches) { + if (resolved.isEmpty()) { + resolved.add(m) + } else { + val last = resolved.last() + if (m.first.first >= last.first.last + 1) { + // Non-overlapping — safe add + resolved.add(m) + } else if (m.first.first > last.first.first) { + // Overlap, this match starts later — it wins the overlapping portion + // Keep the last match's pre-overlap, then replace + resolved.removeAt(resolved.lastIndex) + // Split: keep text before overlap from last match + if (last.first.first < m.first.first) { + resolved.add(last.first.first.. m.first.last) { + resolved.add(m.first.last + 1..last.first.last to last.second) + } + } + // else same start position — first match wins, skip this one + } + } + + var pos = 0 + for ((range, color) in resolved) { + if (range.first > pos) { + append(code.substring(pos, range.first)) + } + withStyle(SpanStyle(color = color)) { + append(code.substring(range.first, range.last + 1)) + } + pos = range.last + 1 + } + if (pos < code.length) { + append(code.substring(pos)) + } + } + +private data class TokenPattern( + val regex: Regex, + val color: Color, +) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CopyFeedback.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CopyFeedback.kt new file mode 100644 index 00000000..2381d0ea --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CopyFeedback.kt @@ -0,0 +1,29 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import kotlinx.coroutines.delay + +/** + * Remembers a copy feedback state and automatically resets it to `false` + * after 1500ms when set to `true`. + * + * If `copied.value` is already `true`, setting it to `true` again does not + * restart the active timer. + */ +@Composable +internal fun rememberCopyFeedback(): MutableState { + val copied = remember { mutableStateOf(false) } + + LaunchedEffect(copied.value) { + if (copied.value) { + delay(1500) + copied.value = false + } + } + + return copied +} 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 deleted file mode 100644 index 0c3ed069..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt +++ /dev/null @@ -1,728 +0,0 @@ -package com.m57.hermescontrol.ui.chat.components - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.InfiniteRepeatableSpec -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilterChip -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -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.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.m57.hermescontrol.R -import com.m57.hermescontrol.theme.CodeComment -import com.m57.hermescontrol.theme.CodeKeyword -import com.m57.hermescontrol.theme.CodeNumber -import com.m57.hermescontrol.theme.CodePunctuation -import com.m57.hermescontrol.theme.CodeString -import com.m57.hermescontrol.theme.CodeTerminalBg -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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext - -// ── ReasoningCard ───────────────────────────────────────────────────────── - -/** - * Collapsible card showing the assistant's reasoning trace - * (reasoning-model thinking steps) before the final answer. - * - * Collapsed: "🧠 Reasoning · {N} steps" with chevron. - * 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]. - */ -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ReasoningCard( - reasoningText: String, - isStreaming: Boolean = false, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - var expanded by remember { mutableStateOf(false) } - var fullHeight by remember { mutableStateOf(false) } - var copied by remember { mutableStateOf(false) } - val scrollState = rememberScrollState() - val stepCount = remember(reasoningText) { reasoningText.count { it == '\n' } + 1 } - val capHeight = with(LocalConfiguration.current) { (screenHeightDp * 0.4f).dp } - - // Copy feedback: briefly show "Copied" then revert - LaunchedEffect(copied) { - if (copied) { - delay(1500) - copied = false - } - } - - Card( - modifier = - modifier - .fillMaxWidth() - .then(if (!isStreaming) Modifier.animateContentSize() else Modifier) - .combinedClickable( - onClick = { expanded = !expanded }, - onLongClick = { - val clipboard = - context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager - ?: return@combinedClickable - clipboard.setPrimaryClip(ClipData.newPlainText(null, reasoningText)) - copied = true - }, - ).testTag("reasoning_card"), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, - ), - shape = RoundedCornerShape(12.dp), - ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "🧠", - fontSize = 14.sp, - ) - Spacer(Modifier.width(8.dp)) - Text( - text = stringResource(R.string.message_reasoning_steps, stepCount), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) - if (copied) { - Spacer(Modifier.width(4.dp)) - Text( - text = stringResource(R.string.reasoning_copied), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) - } - Icon( - imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) - } - AnimatedVisibility(visible = expanded) { - Column { - 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)) - } - if (fullHeight) { - TextButton(onClick = { fullHeight = false }) { - Text(stringResource(R.string.reasoning_show_less)) - } - } else if (scrollState.maxValue > 0) { - TextButton(onClick = { fullHeight = true }) { - Text(stringResource(R.string.reasoning_show_full)) - } - } - } - } - } - } -} - -/** - * Small pulsing dot shown at the bottom of [ReasoningCard] while streaming. - */ -@Composable -private fun ReasoningPulsingDot(modifier: Modifier = Modifier) { - val infiniteTransition = rememberInfiniteTransition(label = "reasoning_pulse") - val pulseSpec: InfiniteRepeatableSpec = - remember { - infiniteRepeatable( - animation = tween(600, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ) - } - val alpha by infiniteTransition.animateFloat( - initialValue = 0.3f, - targetValue = 1f, - animationSpec = pulseSpec, - label = "reasoning_dot_alpha", - ) - Box( - modifier = - modifier - .size(8.dp) - .clip(CircleShape) - .graphicsLayer { this.alpha = alpha } - .testTag("reasoning_pulsing_dot"), - ) { - androidx.compose.material3.Surface( - modifier = Modifier.size(8.dp), - shape = CircleShape, - color = MaterialTheme.colorScheme.primary, - ) {} - } -} - -// ── CodeBlockCard ───────────────────────────────────────────────────────── - -/** - * Terminal-style card for a fenced code block with syntax highlighting, - * a language badge, and a copy button. - */ -@Composable -fun CodeBlockCard( - code: String, - language: String?, - onCopy: (String) -> Unit, - modifier: Modifier = Modifier, -) { - val highlighted by produceState( - initialValue = remember(code) { AnnotatedString(code) }, - key1 = code, - ) { - value = - withContext(Dispatchers.Default) { - highlightSyntax(code) - } - } - - CodeTerminalCard( - textToCopy = code, - modifier = modifier, - testTag = "code_block", - title = language?.takeIf { it.isNotBlank() }?.uppercase(), - onCopy = onCopy, - copyContentDescription = "Copy code", - ) { - Text( - text = highlighted, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - color = CodeTerminalText, - softWrap = false, - modifier = - Modifier - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 6.dp), - ) - } -} - -// Hoisted token patterns for syntax highlighting — compiled once. -private val HIGHLIGHT_TOKENS = - listOf( - TokenPattern(Regex("""//[^\n]*"""), CodeComment), - TokenPattern(Regex("""/\*[\s\S]*?\*/"""), CodeComment), - TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*"(?=\s*:)"""), CodeKeyword), - TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*""""), CodeString), - TokenPattern(Regex("""'[^'\\]*(\\.[^'\\]*)*'"""), CodeString), - TokenPattern(Regex("""`[^`\\]*(\\.[^`\\]*)*`"""), CodeString), - TokenPattern(Regex("""\b0[xX][0-9a-fA-F]+\b"""), CodeNumber), - TokenPattern(Regex("""\b\d+\.?\d*(?:[eE][+-]?\d+)?\b"""), CodeNumber), - TokenPattern( - Regex( - """\b(?:val|var|fun|class|object|interface|enum|data|sealed|open|abstract|""" + - """override|private|protected|public|internal|import|package|""" + - """if|else|when|for|while|do|return|throw|try|catch|finally|""" + - """true|false|null|this|super|is|in|as|typealias|companion|""" + - """init|constructor|by|get|set|field|value|suspend|inline|""" + - """infix|operator|tailrec|external|annotation)\b""", - ), - CodeKeyword, - ), - TokenPattern(Regex("""[{}()\[\];:.]"""), CodePunctuation), - ) - -/** - * Builds an [AnnotatedString] from [code] with syntax highlighting colours - * applied via token regexes. Covers keywords, strings, comments, numbers, - * and punctuation — everything else remains the default light-grey. - */ -internal fun highlightSyntax(code: String): AnnotatedString = - buildAnnotatedString { - val tokens = HIGHLIGHT_TOKENS - var lastIndex = 0 - val matches = mutableListOf>() - - for (pattern in tokens) { - for (match in pattern.regex.findAll(code)) { - matches.add(match.range to pattern.color) - } - } - - matches.sortBy { it.first.first } - // Resolve overlaps: later-in-text wins for same-pos, else first-match - val resolved = mutableListOf>() - for (m in matches) { - if (resolved.isEmpty()) { - resolved.add(m) - } else { - val last = resolved.last() - if (m.first.first >= last.first.last + 1) { - // Non-overlapping — safe add - resolved.add(m) - } else if (m.first.first > last.first.first) { - // Overlap, this match starts later — it wins the overlapping portion - // Keep the last match's pre-overlap, then replace - resolved.removeAt(resolved.lastIndex) - // Split: keep text before overlap from last match - if (last.first.first < m.first.first) { - resolved.add(last.first.first.. m.first.last) { - resolved.add(m.first.last + 1..last.first.last to last.second) - } - } - // else same start position — first match wins, skip this one - } - } - - var pos = 0 - for ((range, color) in resolved) { - if (range.first > pos) { - append(code.substring(pos, range.first)) - } - withStyle(SpanStyle(color = color)) { - append(code.substring(range.first, range.last + 1)) - } - pos = range.last + 1 - } - if (pos < code.length) { - append(code.substring(pos)) - } - } - -private data class TokenPattern( - val regex: Regex, - val color: Color, -) - -// ── ClarifyBubble ───────────────────────────────────────────────────────── - -/** - * Centered dashed-border bubble showing a clarify question (or batch of questions) with - * selectable option chips, multi-select toggles, open-ended input, and a dismiss button. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun ClarifyBubble( - clarifyRequest: ClarifyUi, - onRespondSingle: (String) -> Unit, - onRespondBatch: (Map) -> Unit, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, -) { - val questions = clarifyRequest.resolvedQuestions - val isBatch = questions.size > 1 - - var selectedChoicesByQid by remember(clarifyRequest) { - mutableStateOf>>(emptyMap()) - } - var customTextByQid by remember(clarifyRequest) { - mutableStateOf>(emptyMap()) - } - - Surface( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 4.dp) - .testTag("clarify_bubble"), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow, - border = - BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.outline, - ), - ) { - Column( - modifier = Modifier.padding(14.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - questions.forEachIndexed { index, q -> - val lockedAnswer = clarifyRequest.lockedAnswers[q.qid] - if (index > 0) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 12.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - - if (isBatch) { - Text( - text = "${index + 1}. ${q.question}", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.fillMaxWidth(), - ) - } else { - Text( - text = q.question, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.fillMaxWidth(), - ) - } - - if (lockedAnswer != null) { - Surface( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - shape = RoundedCornerShape(8.dp), - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(6.dp)) - Text( - text = "Answered: $lockedAnswer", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - } else { - if (q.multiSelect) { - Spacer(Modifier.height(2.dp)) - Text( - text = "Select all that apply", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - ) - } - - if (q.choices.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - q.choices.forEach { choice -> - val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet() - val isSelected = selectedSet.contains(choice) - FilterChip( - selected = isSelected, - onClick = { - if (q.multiSelect) { - val updated = if (isSelected) selectedSet - choice else selectedSet + choice - selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) - } else { - if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) { - // Fast 1-tap respond for lone single-select question when no custom text is entered - onRespondSingle(choice) - } else { - val updated = if (isSelected) emptySet() else setOf(choice) - selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) - } - } - }, - label = { Text(choice) }, - leadingIcon = - if (isSelected) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - } - } else { - null - }, - ) - } - } - } - - Spacer(Modifier.height(8.dp)) - val typed = customTextByQid[q.qid].orEmpty() - OutlinedTextField( - value = typed, - onValueChange = { newText -> - customTextByQid = customTextByQid + (q.qid to newText) - }, - label = { - Text( - if (q.choices.isEmpty()) { - stringResource(R.string.message_your_response) - } else { - "Other (optional)" - }, - ) - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - } - } - - Spacer(Modifier.height(12.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - ) { - val hasAnyInput = - questions.any { q -> - (selectedChoicesByQid[q.qid]?.isNotEmpty() == true) || - (!customTextByQid[q.qid].isNullOrBlank()) - } - - FilledTonalButton( - onClick = { - if (isBatch) { - val answers = - questions.associate { q -> - val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty() - val custom = customTextByQid[q.qid]?.trim().orEmpty() - val finalAns = - when { - selectedList.isNotEmpty() && custom.isNotEmpty() -> { - (selectedList + custom).joinToString(", ") - } - - selectedList.isNotEmpty() -> { - selectedList.joinToString(", ") - } - - else -> { - custom - } - } - q.qid to finalAns - } - onRespondBatch(answers) - } else { - val q = questions.first() - val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty() - val custom = customTextByQid[q.qid]?.trim().orEmpty() - val finalAns = - when { - selectedList.isNotEmpty() && custom.isNotEmpty() -> { - (selectedList + custom).joinToString(", ") - } - - selectedList.isNotEmpty() -> { - selectedList.joinToString(", ") - } - - else -> { - custom - } - } - if (finalAns.isNotBlank()) { - onRespondSingle(finalAns) - } - } - }, - enabled = hasAnyInput, - ) { - Text(if (isBatch) "Submit All" else "Send") - } - TextButton(onClick = onDismiss) { - Text("Dismiss") - } - } - } - } -} - -/** - * Backward-compatible overload for legacy callers and unit tests. - */ -@Composable -fun ClarifyBubble( - text: String, - options: List, - onOptionSelected: (String) -> Unit, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, -) { - ClarifyBubble( - clarifyRequest = ClarifyUi(text = text, options = options), - onRespondSingle = onOptionSelected, - onRespondBatch = { answers -> - answers.values.firstOrNull()?.let(onOptionSelected) - }, - onDismiss = onDismiss, - modifier = modifier, - ) -} - -// ── TypingIndicator ─────────────────────────────────────────────────────── - -/** - * Three subtle dots shown while the assistant is waiting to produce visible - * content. Staggered opacity/scale animation keeps the indicator lightweight - * without the distracting vertical bounce used by the old chat renderer. - */ -@Composable -fun TypingIndicator(modifier: Modifier = Modifier) { - val description = stringResource(R.string.chat_agent_status_typing) - Row( - modifier = - modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - .semantics { contentDescription = description } - .testTag("typing_indicator"), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - for (i in 0 until 3) { - TypingDot(delayMs = i * 150) - } - } -} - -@Composable -private fun TypingDot(delayMs: Int) { - val infiniteTransition = rememberInfiniteTransition(label = "typing_dot_$delayMs") - val typingSpec: InfiniteRepeatableSpec = - remember(delayMs) { - infiniteRepeatable( - animation = tween(700, delayMillis = delayMs, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ) - } - val offset by infiniteTransition.animateFloat( - initialValue = 0.85f, - targetValue = 1f, - animationSpec = typingSpec, - label = "typing_dot_scale_$delayMs", - ) - val alpha by infiniteTransition.animateFloat( - initialValue = 0.45f, - targetValue = 1f, - animationSpec = typingSpec, - label = "typing_dot_alpha_$delayMs", - ) - Box( - modifier = - Modifier - .size(8.dp) - .clip(CircleShape) - .graphicsLayer { - this.scaleX = offset - this.scaleY = offset - this.alpha = alpha - }, - ) { - androidx.compose.material3.Surface( - modifier = Modifier.size(8.dp), - shape = CircleShape, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) {} - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt new file mode 100644 index 00000000..6f1517d9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt @@ -0,0 +1,217 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.InfiniteRepeatableSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +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.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.MarkdownText + +/** + * Collapsible card showing the assistant's reasoning trace + * (reasoning-model thinking steps) before the final answer. + * + * Collapsed: "🧠 Reasoning · {N} steps" with chevron. + * 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]. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ReasoningCard( + reasoningText: String, + isStreaming: Boolean = false, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var expanded by remember { mutableStateOf(false) } + var fullHeight by remember { mutableStateOf(false) } + // Copy feedback: briefly show "Copied" then revert + var copied by rememberCopyFeedback() + val scrollState = rememberScrollState() + val stepCount = remember(reasoningText) { reasoningText.count { it == '\n' } + 1 } + val capHeight = with(LocalConfiguration.current) { (screenHeightDp * 0.4f).dp } + + Card( + modifier = + modifier + .fillMaxWidth() + .then(if (!isStreaming) Modifier.animateContentSize() else Modifier) + .combinedClickable( + onClick = { expanded = !expanded }, + onLongClick = { + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + ?: return@combinedClickable + clipboard.setPrimaryClip(ClipData.newPlainText(null, reasoningText)) + copied = true + }, + ).testTag("reasoning_card"), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + shape = RoundedCornerShape(12.dp), + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "🧠", + fontSize = 14.sp, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.message_reasoning_steps, stepCount), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (copied) { + Spacer(Modifier.width(4.dp)) + Text( + text = stringResource(R.string.reasoning_copied), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + AnimatedVisibility(visible = expanded) { + Column { + 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)) + } + if (fullHeight) { + TextButton(onClick = { fullHeight = false }) { + Text(stringResource(R.string.reasoning_show_less)) + } + } else if (scrollState.maxValue > 0) { + TextButton(onClick = { fullHeight = true }) { + Text(stringResource(R.string.reasoning_show_full)) + } + } + } + } + } + } +} + +/** + * Small pulsing dot shown at the bottom of [ReasoningCard] while streaming. + */ +@Composable +private fun ReasoningPulsingDot(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "reasoning_pulse") + val pulseSpec: InfiniteRepeatableSpec = + remember { + infiniteRepeatable( + animation = tween(600, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ) + } + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = pulseSpec, + label = "reasoning_dot_alpha", + ) + Box( + modifier = + modifier + .size(8.dp) + .clip(CircleShape) + .graphicsLayer { this.alpha = alpha } + .testTag("reasoning_pulsing_dot"), + ) { + Surface( + modifier = Modifier.size(8.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + ) {} + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/TypingIndicator.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/TypingIndicator.kt new file mode 100644 index 00000000..be8adc15 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/TypingIndicator.kt @@ -0,0 +1,94 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.animation.core.InfiniteRepeatableSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R + +/** + * Three subtle dots shown while the assistant is waiting to produce visible + * content. Staggered opacity/scale animation keeps the indicator lightweight + * without the distracting vertical bounce used by the old chat renderer. + */ +@Composable +fun TypingIndicator(modifier: Modifier = Modifier) { + val description = stringResource(R.string.chat_agent_status_typing) + Row( + modifier = + modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .semantics { contentDescription = description } + .testTag("typing_indicator"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + for (i in 0 until 3) { + TypingDot(delayMs = i * 150) + } + } +} + +@Composable +private fun TypingDot(delayMs: Int) { + val infiniteTransition = rememberInfiniteTransition(label = "typing_dot_$delayMs") + val typingSpec: InfiniteRepeatableSpec = + remember(delayMs) { + infiniteRepeatable( + animation = tween(700, delayMillis = delayMs, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ) + } + val offset by infiniteTransition.animateFloat( + initialValue = 0.85f, + targetValue = 1f, + animationSpec = typingSpec, + label = "typing_dot_scale_$delayMs", + ) + val alpha by infiniteTransition.animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = typingSpec, + label = "typing_dot_alpha_$delayMs", + ) + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .graphicsLayer { + this.scaleX = offset + this.scaleY = offset + this.alpha = alpha + }, + ) { + Surface( + modifier = Modifier.size(8.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) {} + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 8899c7d4..52b226bb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -22,10 +22,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -43,13 +40,13 @@ import com.m57.hermescontrol.ui.chat.InlineAttachment import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.chat.TokenEstimator import com.m57.hermescontrol.ui.chat.components.ReasoningCard -import kotlinx.coroutines.delay +import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback import kotlinx.coroutines.launch /** * Full-bleed renderer for ONE agent (assistant) message (issue #866). * - * Unlike [com.m57.hermescontrol.ui.chat.ChatBubble], agent prose renders + * Unlike [com.m57.hermescontrol.ui.chat.UserBubble], agent prose renders * directly on the background — no bubble container, no width cap — with a * trailing copy affordance. User messages keep their bubbles; this composable * is only used for ASSISTANT messages. @@ -77,15 +74,8 @@ internal fun FullBleedAgentMessage( val textColor = MaterialTheme.colorScheme.onSurface val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() - var copied by remember { mutableStateOf(false) } - // Copy feedback: briefly show ✓ then revert - LaunchedEffect(copied) { - if (copied) { - delay(1500) - copied = false - } - } + var copied by rememberCopyFeedback() Column( modifier = 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 3016ec59..1db9f185 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 @@ -32,7 +32,6 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalChatFontScale -import com.m57.hermescontrol.ui.chat.ChatBubble import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatSearchState import com.m57.hermescontrol.ui.chat.ChatViewModel @@ -40,6 +39,7 @@ import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.ToolCallDivider +import com.m57.hermescontrol.ui.chat.UserBubble import com.m57.hermescontrol.ui.chat.VaultCodePromptUi import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi @@ -332,7 +332,7 @@ fun FullBleedChatList( contentType = FullBleedContentType.USER, ) { Column(modifier = Modifier.padding(bottom = 12.dp)) { - renderChatBubble( + renderUserBubble( message = userMessage, searchQuery = if (searchState.isActive) searchState.query else "", isCurrentMatch = @@ -512,7 +512,7 @@ fun FullBleedChatList( } @Composable -private fun renderChatBubble( +private fun renderUserBubble( message: ChatMessage, searchQuery: String, isCurrentMatch: Boolean, @@ -524,7 +524,7 @@ private fun renderChatBubble( messageStatsEnabled: Boolean, showUserMessageTokens: Boolean, ) { - ChatBubble( + UserBubble( message = message, searchQuery = searchQuery, isCurrentMatch = isCurrentMatch, From af0c508c1f641b3b7b5543cabbf009c132a7e02e Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:29:12 +0300 Subject: [PATCH 044/107] refactor(chat): share inline attachment list (#1313) (#1350) --- .../hermescontrol/ui/chat/InlineAttachment.kt | 33 +++++++++++++++++++ .../m57/hermescontrol/ui/chat/UserBubble.kt | 26 ++++++--------- .../chat/fullbleed/FullBleedAgentMessage.kt | 30 +++++++---------- 3 files changed, 55 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/InlineAttachment.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/InlineAttachment.kt index cba72589..c5716f4b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/InlineAttachment.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/InlineAttachment.kt @@ -34,6 +34,39 @@ import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment +/** + * Renders a message's attachments, shared by user bubbles and agent messages. + * Emits directly into the caller's Column (no wrapper) so spacing is unchanged: + * 6dp before the list, 4dp after each item. Renders nothing when empty. + */ +@Composable +internal fun InlineAttachmentList( + attachments: List?, + textColor: Color, + onOpen: (Attachment) -> Unit, + onSave: (Attachment) -> Unit, + savingPath: String?, + openingPath: String?, + canSave: Boolean, + onImageClick: (ImageViewerModel) -> Unit, +) { + if (attachments.isNullOrEmpty()) return + Spacer(modifier = Modifier.height(6.dp)) + attachments.forEach { attachment -> + InlineAttachment( + attachment = attachment, + textColor = textColor, + onOpen = onOpen, + onSave = onSave, + savingPath = savingPath, + openingPath = openingPath, + canSave = canSave, + onImageClick = onImageClick, + ) + Spacer(modifier = Modifier.height(4.dp)) + } +} + /** * Renders an attachment inline inside a chat bubble. * Images are displayed as thumbnails; other files show a compact card. diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt index 33332774..d4a73087 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt @@ -177,22 +177,16 @@ fun UserBubble( ) } // Render inline attachments - if (!message.attachments.isNullOrEmpty()) { - Spacer(modifier = Modifier.height(6.dp)) - message.attachments.forEach { attachment -> - InlineAttachment( - attachment = attachment, - textColor = userBubbleTextColor, - onOpen = { onOpenAttachment(it) }, - onSave = { onSaveAttachment(it) }, - savingPath = savingAttachmentPath, - openingPath = openingAttachmentPath, - canSave = canSaveAttachment, - onImageClick = onImageClick, - ) - Spacer(modifier = Modifier.height(4.dp)) - } - } + InlineAttachmentList( + attachments = message.attachments, + textColor = userBubbleTextColor, + onOpen = onOpenAttachment, + onSave = onSaveAttachment, + savingPath = savingAttachmentPath, + openingPath = openingAttachmentPath, + canSave = canSaveAttachment, + onImageClick = onImageClick, + ) if (!message.isStreaming) { FlowRow( modifier = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 52b226bb..318f3806 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -36,7 +36,7 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ImageViewerModel -import com.m57.hermescontrol.ui.chat.InlineAttachment +import com.m57.hermescontrol.ui.chat.InlineAttachmentList import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.chat.TokenEstimator import com.m57.hermescontrol.ui.chat.components.ReasoningCard @@ -109,24 +109,18 @@ internal fun FullBleedAgentMessage( } } - // Render inline attachments (mirrors ChatBubble so agent-delivered + // Inline attachments (shared with UserBubble so agent-delivered // media — images, files — shows in full-bleed mode too). - if (!message.attachments.isNullOrEmpty()) { - Spacer(modifier = Modifier.height(6.dp)) - message.attachments.forEach { attachment -> - InlineAttachment( - attachment = attachment, - textColor = textColor, - onOpen = { onOpenAttachment(it) }, - onSave = { onSaveAttachment(it) }, - savingPath = savingAttachmentPath, - openingPath = openingAttachmentPath, - canSave = canSaveAttachment, - onImageClick = onImageClick, - ) - Spacer(modifier = Modifier.height(4.dp)) - } - } + InlineAttachmentList( + attachments = message.attachments, + textColor = textColor, + onOpen = onOpenAttachment, + onSave = onSaveAttachment, + savingPath = savingAttachmentPath, + openingPath = openingAttachmentPath, + canSave = canSaveAttachment, + onImageClick = onImageClick, + ) if (!message.isStreaming && message.content.isNotBlank()) { val showTokenStat = From 15bc0e21c7c7050efef0e335ba8ca955dcdf30ce Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:25:57 +0300 Subject: [PATCH 045/107] refactor(chat): unify media types and viewer (#1328, #1329) (#1353) --- .../ui/chat/ImageBytesResolver.kt | 15 ++-- .../m57/hermescontrol/ui/chat/MediaTypes.kt | 84 +++++++------------ .../ui/chat/ImageBytesResolverTest.kt | 5 ++ .../hermescontrol/ui/chat/MediaTypesTest.kt | 24 ++++++ 4 files changed, 64 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt index 431c6898..84894f01 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt @@ -114,15 +114,12 @@ object ImageBytesResolver { }.getOrElse { e -> Result.Error(e.message ?: "Could not download image") } } + /** Image-only view of the shared MIME table: anything that isn't `image/` saves as `.img`. */ fun extensionForMime(mime: String): String = - when (mime.substringBefore(';').trim().lowercase()) { - "image/jpeg", "image/jpg" -> "jpg" - "image/png" -> "png" - "image/gif" -> "gif" - "image/webp" -> "webp" - "image/bmp" -> "bmp" - "image/heic", "image/heif" -> "heic" - "image/svg+xml" -> "svg" - else -> "img" + if (mime.trim().startsWith("image/", ignoreCase = true)) { + com.m57.hermescontrol.ui.chat + .extensionForMime(mime) + } else { + "img" } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt index 227447d8..e3c0bf09 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt @@ -140,67 +140,41 @@ fun normalizeFrameworkMime(mime: String?): String = ?.takeIf { it.isNotBlank() } ?: "application/octet-stream" +/** + * MIME aliases and preferred extensions that [MEDIA_BY_EXT] can't express + * (it's keyed by extension, so `image/jpeg` would otherwise resolve to its + * first entry, `jpeg`). HEIC is save-only here: it isn't classified as inline media. + */ +private val EXT_BY_MIME_ALIAS: Map = + mapOf( + "image/jpeg" to "jpg", + "image/jpg" to "jpg", + "image/heic" to "heic", + "image/heif" to "heic", + "audio/mp3" to "mp3", + "audio/x-m4a" to "m4a", + "audio/x-wav" to "wav", + "audio/wave" to "wav", + "application/x-flac" to "flac", + "application/ogg" to "ogg", + ) + /** Returns the canonical file extension for a given MIME type. */ fun extensionForMime(mime: String): String { val clean = mime.substringBefore(';').trim().lowercase() - for ((ext, pair) in MEDIA_BY_EXT) { - if (pair.second + EXT_BY_MIME_ALIAS[clean]?.let { return it } + MEDIA_BY_EXT.entries + .firstOrNull { (_, pair) -> + pair.second .substringBefore(';') .trim() .lowercase() == clean - ) { - return ext - } - } - return when (clean) { - "image/jpeg", "image/jpg" -> { - "jpg" - } - - "video/mp4" -> { - "mp4" - } - - "video/x-matroska" -> { - "mkv" - } - - "video/quicktime" -> { - "mov" - } - - "audio/mpeg", "audio/mp3" -> { - "mp3" - } - - "audio/mp4", "audio/x-m4a" -> { - "m4a" - } - - "audio/wav", "audio/x-wav", "audio/wave" -> { - "wav" - } - - "audio/flac", "application/x-flac" -> { - "flac" - } - - "audio/ogg", "application/ogg" -> { - "ogg" - } - - "audio/aac" -> { - "aac" - } - - else -> { - when { - clean.startsWith("video/") -> "mp4" - clean.startsWith("audio/") -> "mp3" - clean.startsWith("image/") -> "img" - else -> "bin" - } - } + }?.let { return it.key } + return when { + clean.startsWith("video/") -> "mp4" + clean.startsWith("audio/") -> "mp3" + clean.startsWith("image/") -> "img" + else -> "bin" } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt index 4b458a8e..b01aff74 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt @@ -35,6 +35,11 @@ class ImageBytesResolverTest { assertEquals("heic", ImageBytesResolver.extensionForMime("image/heic")) assertEquals("svg", ImageBytesResolver.extensionForMime("image/svg+xml")) assertEquals("img", ImageBytesResolver.extensionForMime("image/unknown")) + assertEquals("heic", ImageBytesResolver.extensionForMime("image/heif")) + assertEquals("bmp", ImageBytesResolver.extensionForMime("image/bmp")) + // Non-image content types stay `.img`, not `.bin`/`.pdf`. + assertEquals("img", ImageBytesResolver.extensionForMime("application/pdf")) + assertEquals("img", ImageBytesResolver.extensionForMime("text/html")) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaTypesTest.kt index 7060ef35..86fb2a90 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaTypesTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaTypesTest.kt @@ -30,6 +30,30 @@ class MediaTypesTest { assertEquals("application/octet-stream", mediaMimeForPath("/tmp/x.weird")) } + @Test + fun `extension for mime uses one table plus aliases`() { + // Table entries, including params and case. + assertEquals("png", extensionForMime("image/png")) + assertEquals("png", extensionForMime(" IMAGE/PNG; charset=binary")) + assertEquals("ogg", extensionForMime("audio/ogg; codecs=opus")) + assertEquals("mkv", extensionForMime("video/x-matroska")) + assertEquals("pdf", extensionForMime("application/pdf")) + // Aliases win over first-table-match. + assertEquals("jpg", extensionForMime("image/jpeg")) + assertEquals("jpg", extensionForMime("image/jpg")) + assertEquals("heic", extensionForMime("image/heif")) + assertEquals("mp3", extensionForMime("audio/mp3")) + assertEquals("m4a", extensionForMime("audio/x-m4a")) + assertEquals("wav", extensionForMime("audio/wave")) + assertEquals("flac", extensionForMime("application/x-flac")) + assertEquals("ogg", extensionForMime("application/ogg")) + // Family fallbacks. + assertEquals("mp4", extensionForMime("video/unknown")) + assertEquals("mp3", extensionForMime("audio/unknown")) + assertEquals("img", extensionForMime("image/unknown")) + assertEquals("bin", extensionForMime("application/unknown")) + } + @Test fun `name extraction handles urls and paths`() { assertEquals("report.pdf", mediaNameFromPath("https://gw/api/files/download?path=%2Ftmp%2Freport.pdf")) From b199c0347a37dcc6893b6f210562f999a3c5a221 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:21:59 +0300 Subject: [PATCH 046/107] refactor(chat): one media viewer and export pipeline (#1328) (#1355) --- .../ui/chat/MediaViewerDeviceTest.kt | 53 ++++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 9 +- .../ui/chat/ImageBytesResolver.kt | 125 --------- .../ui/chat/ImageViewerDialog.kt | 238 ------------------ .../hermescontrol/ui/chat/MediaImageStore.kt | 65 +---- .../ui/chat/components/ImageViewerContent.kt | 155 ++++++++++++ .../ui/chat/components/MediaExportActions.kt | 217 ++++++++++++++++ .../ui/chat/components/MediaViewerDialog.kt | 198 +-------------- app/src/main/res/values-ar/strings.xml | 5 - app/src/main/res/values-ko/strings.xml | 5 - app/src/main/res/values-zh/strings.xml | 5 - app/src/main/res/values/strings.xml | 5 - .../ui/chat/ImageBytesResolverTest.kt | 96 ------- 13 files changed, 450 insertions(+), 726 deletions(-) delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt delete mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt index 49617fb4..f7eea804 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt @@ -210,6 +210,59 @@ class MediaViewerDeviceTest { assertEquals(expectedSize, actualSize) } + @Test + fun imageUsesImageSurfaceAndStreamsSaveToDownloads() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + val name = "viewer-image-${System.nanoTime()}.png" + val file = File(context.cacheDir, name) + val bitmap = android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888) + file.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + compose.setContent { + var visible by remember { mutableStateOf(true) } + HermesControlTheme { + if (visible) { + MediaViewerDialog(file.toURI().toString(), { visible = false }, name, "image/png") + } + } + } + awaitTag("media_image") + // Images never get the player surface. + assertTrue(compose.onAllNodesWithTag("media_play_pause_button").fetchSemanticsNodes().isEmpty()) + compose.onNodeWithTag("media_save_button").assertIsDisplayed().performClick() + + val resolver = context.contentResolver + var savedUri: Uri? = null + compose.waitUntil(15_000) { + resolver + .query( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.DISPLAY_NAME} = ?", + arrayOf(name), + null, + )?.use { c -> + if (c.moveToFirst()) { + val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) + savedUri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id) + true + } else { + false + } + } ?: false + } + try { + assertArrayEquals(file.readBytes(), resolver.openInputStream(savedUri!!)?.use { it.readBytes() }) + } finally { + savedUri?.let { resolver.delete(it, null, null) } + } + compose.onNodeWithTag("media_close_button").performClick() + compose.waitUntil(15_000) { + compose.onAllNodesWithTag("media_viewer_dialog").fetchSemanticsNodes().isEmpty() + } + } + @Test fun invalidSourceShowsRetryAndCanClose() { compose.setContent { 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 1288de73..919ec93e 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 @@ -106,6 +106,7 @@ import com.m57.hermescontrol.ui.chat.components.ChatTimelineSheet import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet import com.m57.hermescontrol.ui.chat.components.ContextUsageChip +import com.m57.hermescontrol.ui.chat.components.MediaViewerDialog import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay import com.m57.hermescontrol.ui.chat.components.ReloginDialog import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard @@ -1066,9 +1067,11 @@ fun ChatScreen( } viewingImage?.let { image -> - ImageViewerDialog( - image = image, - onDismiss = { viewingImage = null }, + MediaViewerDialog( + mediaUri = image.model, + onDismissRequest = { viewingImage = null }, + title = image.name.ifBlank { null }, + mimeType = image.mimeType, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt deleted file mode 100644 index 84894f01..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt +++ /dev/null @@ -1,125 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -import android.content.Context -import android.net.Uri -import com.m57.hermescontrol.data.remote.OkHttpProvider -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.Request -import java.util.Base64 - -/** - * Resolve a chat image [ImageViewerModel.model] into raw bytes so the viewer can - * Save/Share it *locally on the device* — never back to the Hermes server - * (issue #723). - * - * Every model kind handled here is exactly what Coil already renders in the - * bubble, so a model that displays is also resolvable here: - * - `data:image/...;base64,...` — agent-delivered inline media, - * - `content://...` — a locally-picked user image, - * - `http(s)://...` — gateway-served URL (carries the `?token=` auth param the - * same way the app fetches it; no backend change needed). - * - * All I/O runs on [Dispatchers.IO]. - */ -object ImageBytesResolver { - /** Outcome of [resolve]. */ - sealed interface Result { - /** Resolved bytes plus the concrete MIME type and file extension. */ - data class Bytes( - val bytes: ByteArray, - val mimeType: String, - val extension: String, - ) : Result - - /** Could not load the image; [message] is safe to surface to the user. */ - data class Error( - val message: String, - ) : Result - } - - suspend fun resolve( - context: Context, - model: String, - fallbackMime: String, - ): Result = - withContext(Dispatchers.IO) { - runCatching { - when { - model.startsWith("data:") -> decodeDataUrl(model, fallbackMime) - model.startsWith("content://") -> readContentUri(context, model, fallbackMime) - model.startsWith("https://") || model.startsWith("http://") -> fetchHttp(model, fallbackMime) - else -> Result.Error("Unsupported image source") - } - }.getOrElse { e -> Result.Error(e.message ?: "Failed to load image") } - } - - private fun decodeDataUrl( - model: String, - fallbackMime: String, - ): Result { - val comma = model.indexOf(',') - if (comma < 0) return Result.Error("Malformed data URL") - val meta = model.substring(0, comma) - val data = model.substring(comma + 1).replace(Regex("\\s+"), "") - val mime = - meta - .substringAfter("data:", "image/*") - .substringBefore(";") - .ifBlank { fallbackMime } - if (!meta.contains(";base64", ignoreCase = true)) { - return Result.Error("Only base64 data URLs are supported") - } - val bytes = - runCatching { Base64.getDecoder().decode(data) } - .getOrElse { return Result.Error("Could not decode image data") } - return Result.Bytes(bytes, mime, extensionForMime(mime)) - } - - private fun readContentUri( - context: Context, - model: String, - fallbackMime: String, - ): Result { - val uri = Uri.parse(model) - val bytes = - runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } } - .getOrNull() - ?: return Result.Error("Could not open local image") - val mime = context.contentResolver.getType(uri) ?: fallbackMime - return Result.Bytes(bytes, mime, extensionForMime(mime)) - } - - private fun fetchHttp( - model: String, - fallbackMime: String, - ): Result { - val request = - Request - .Builder() - .url(model) - .build() - return runCatching { - OkHttpProvider.base.newCall(request).execute().use { resp -> - if (!resp.isSuccessful) return Result.Error("Download failed (HTTP ${resp.code})") - val bytes = resp.body.bytes() - val mime = - resp - .header("Content-Type") - ?.substringBefore(";") - ?.ifBlank { fallbackMime } - ?: fallbackMime - Result.Bytes(bytes, mime, extensionForMime(mime)) - } - }.getOrElse { e -> Result.Error(e.message ?: "Could not download image") } - } - - /** Image-only view of the shared MIME table: anything that isn't `image/` saves as `.img`. */ - fun extensionForMime(mime: String): String = - if (mime.trim().startsWith("image/", ignoreCase = true)) { - com.m57.hermescontrol.ui.chat - .extensionForMime(mime) - } else { - "img" - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt deleted file mode 100644 index f202609d..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt +++ /dev/null @@ -1,238 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -import android.widget.Toast -import androidx.compose.foundation.gestures.detectTransformGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import coil3.compose.AsyncImage -import com.m57.hermescontrol.R -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * Full-screen image viewer (issue #723). - * - * Opens from any chat image (bubble thumbnail, markdown `![alt](url)`, inline - * attachment). Provides pinch-zoom + pan, a downswipe-to-dismiss gesture, and a - * top bar with **Save** (writes to device Downloads via [MediaImageStore]) and - * **Share** (Android share sheet) — both fully device-local, never touching the - * Hermes server. - * - * Save/Share resolve the image bytes through [ImageBytesResolver], which handles - * the same model kinds Coil already renders (`data:`, `content://`, `http(s)`). - */ -@Composable -fun ImageViewerDialog( - image: ImageViewerModel, - onDismiss: () -> Unit, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var scale by remember { mutableStateOf(1f) } - var offsetX by remember { mutableStateOf(0f) } - var offsetY by remember { mutableStateOf(0f) } - var isBusy by remember { mutableStateOf(false) } - - // Resolve string resources at composable scope (Lint forbids reading - // resource *values* from LocalContext.current inside coroutine lambdas). - val savedMsg = stringResource(R.string.image_viewer_saved) - val saveFailedMsg = stringResource(R.string.image_viewer_save_failed) - val loadFailedFmt = stringResource(R.string.image_viewer_load_failed) - val shareTitle = stringResource(R.string.image_viewer_share_title) - val shareFailedMsg = stringResource(R.string.image_viewer_share_failed) - - val onSave: () -> Unit = { - if (!isBusy) { - isBusy = true - scope.launch(Dispatchers.IO) { - val resolved = ImageBytesResolver.resolve(context, image.model, image.mimeType) - val result = - when (resolved) { - is ImageBytesResolver.Result.Bytes -> { - val name = image.name.ifBlank { "hermes-image.${resolved.extension}" } - val uri = - MediaImageStore.saveToDownloads( - context, - resolved.bytes, - name, - resolved.mimeType, - ) - if (uri != null) { - savedMsg - } else { - saveFailedMsg - } - } - - is ImageBytesResolver.Result.Error -> { - String.format(loadFailedFmt, resolved.message) - } - } - withContext(Dispatchers.Main) { - isBusy = false - Toast.makeText(context, result, Toast.LENGTH_SHORT).show() - } - } - } - } - - val onShare: () -> Unit = { - if (!isBusy) { - isBusy = true - scope.launch(Dispatchers.IO) { - val resolved = ImageBytesResolver.resolve(context, image.model, image.mimeType) - val intent = - when (resolved) { - is ImageBytesResolver.Result.Bytes -> { - val name = image.name.ifBlank { "hermes-image.${resolved.extension}" } - MediaImageStore.buildShareIntent( - context, - resolved.bytes, - name, - resolved.mimeType, - ) - } - - is ImageBytesResolver.Result.Error -> { - null - } - } - withContext(Dispatchers.Main) { - isBusy = false - if (intent != null) { - context.startActivity( - android.content.Intent - .createChooser(intent, shareTitle), - ) - } else { - Toast - .makeText( - context, - shareFailedMsg, - Toast.LENGTH_SHORT, - ).show() - } - } - } - } - } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.scrim, - ) { - Box(modifier = Modifier.fillMaxSize()) { - // Zoomable / pannable image. - AsyncImage( - model = image.model, - contentDescription = - image.name.ifBlank { stringResource(R.string.image_viewer_content_desc) }, - modifier = - Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offsetX, - translationY = offsetY, - ).pointerInput(Unit) { - detectTransformGestures( - onGesture = { _, pan, zoom, _ -> - val newScale = (scale * zoom).coerceIn(1f, 5f) - // While zoomed in, allow free panning; at min scale, - // only vertical drags are permitted (for dismiss). - scale = newScale - offsetX = if (newScale > 1f) offsetX + pan.x else 0f - offsetY += pan.y - // Downswipe-to-dismiss when at min zoom. - if (newScale <= 1f && offsetY > 120f) { - onDismiss() - } - }, - ) - }, - contentScale = ContentScale.Fit, - ) - - // Top action bar — Save / Share / Close. - Row( - modifier = - Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.image_viewer_close), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - Row { - if (isBusy) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp).padding(horizontal = 8.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurface, - ) - } else { - IconButton(onClick = onSave, enabled = !isBusy) { - Icon( - imageVector = Icons.Filled.Download, - contentDescription = stringResource(R.string.image_viewer_save), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - IconButton(onClick = onShare, enabled = !isBusy) { - Icon( - imageVector = Icons.Filled.Share, - contentDescription = stringResource(R.string.image_viewer_share), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt index 2cd42077..776fe1d0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt @@ -2,42 +2,21 @@ package com.m57.hermescontrol.ui.chat import android.content.ContentValues import android.content.Context -import android.content.Intent import android.net.Uri import android.os.Build import android.provider.MediaStore -import androidx.core.content.FileProvider -import java.io.ByteArrayInputStream -import java.io.File import java.io.InputStream /** - * Persist / dispatch chat images on the *device* (Downloads / Gallery / share - * sheet) — never back to the Hermes server (issue #723). Every operation works - * only on bytes the viewer already resolved locally, so save/share are fully - * device-local and independent of the backend. + * Streams non-chat downloads (backup archives) into device Downloads. Chat + * media save/share goes through [MediaExportHelper] (issue #1328). */ object MediaImageStore { /** - * Write [bytes] into the device's Download collection via [MediaStore] so the - * file appears in system Downloads / Gallery apps. Returns the public [Uri] - * on success, or `null` if the write failed. - * - * On API 29+ the file lands under `Download/Hermes` using - * `RELATIVE_PATH`; on older APIs it falls back to the top-level - * `Images` collection. - */ - fun saveToDownloads( - context: Context, - bytes: ByteArray, - displayName: String, - mimeType: String, - ): Uri? = saveToDownloads(context, ByteArrayInputStream(bytes), bytes.size.toLong(), displayName, mimeType) - - /** - * Streaming variant of [saveToDownloads] — for large payloads (e.g. backup - * archives, 300+ MB) that must NOT be held in memory as a [ByteArray]. - * Copies [input] to the Download collection in chunks. Same return contract. + * Stream [input] into the device's Download collection via [MediaStore] — + * for large payloads (e.g. backup archives, 300+ MB) that must NOT be held + * in memory. Returns the public [Uri] on success, or `null` on failure. + * On API 29+ the file lands under `Download/Hermes`. */ fun saveToDownloads( context: Context, @@ -85,38 +64,6 @@ object MediaImageStore { } } - /** - * Build a share [Intent] for [bytes] via a [FileProvider] cache file so the - * image can be sent to another app (Messages, WhatsApp, …). Returns `null` - * if the temp file could not be written. - */ - fun buildShareIntent( - context: Context, - bytes: ByteArray, - displayName: String, - mimeType: String, - ): Intent? { - val safe = sanitizeName(displayName) - val dir = File(context.cacheDir, "shared_images").also { it.mkdirs() } - val file = File(dir, safe) - return try { - file.writeBytes(bytes) - val uri = - FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - file, - ) - Intent(Intent.ACTION_SEND).apply { - type = mimeType - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - } catch (e: Exception) { - null - } - } - /** * Replace unsafe characters in a display name and cap its length so it is a * valid [MediaStore] / file name. Preserves the original extension when one diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt new file mode 100644 index 00000000..9ffbe313 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt @@ -0,0 +1,155 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil3.compose.AsyncImage +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.mediaNameFromPath + +/** + * Image surface of [MediaViewerDialog]: pinch-zoom + pan, downswipe-to-dismiss, + * and the shared streaming Save/Share actions. No ExoPlayer is created here. + */ +@Composable +internal fun ImageViewerContent( + mediaUri: String, + onDismissRequest: () -> Unit, + title: String?, + mimeType: String?, +) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + val displayName = + remember(mediaUri, title) { + title?.takeIf { it.isNotBlank() } ?: mediaNameFromPath(mediaUri) + } + val export = rememberMediaExportActions(mediaUri, mimeType, displayName) + + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.fillMaxSize().testTag("media_viewer_dialog"), + color = MaterialTheme.colorScheme.scrim, + ) { + Box(modifier = Modifier.fillMaxSize()) { + AsyncImage( + model = mediaUri, + contentDescription = + title?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.image_viewer_content_desc), + modifier = + Modifier + .fillMaxSize() + .testTag("media_image") + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY, + ).pointerInput(Unit) { + detectTransformGestures( + onGesture = { _, pan, zoom, _ -> + val newScale = (scale * zoom).coerceIn(1f, 5f) + // While zoomed in, allow free panning; at min scale, + // only vertical drags are permitted (for dismiss). + scale = newScale + offsetX = if (newScale > 1f) offsetX + pan.x else 0f + offsetY += pan.y + // Downswipe-to-dismiss when at min zoom. + if (newScale <= 1f && offsetY > 120f) { + onDismissRequest() + } + }, + ) + }, + contentScale = ContentScale.Fit, + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onDismissRequest, + modifier = Modifier.testTag("media_close_button"), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.image_viewer_close), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Row { + if (export.isBusy) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp).padding(horizontal = 8.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurface, + ) + } else { + IconButton( + onClick = export.onSave, + modifier = Modifier.testTag("media_save_button"), + ) { + Icon( + imageVector = Icons.Filled.Download, + contentDescription = stringResource(R.string.image_viewer_save), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + IconButton( + onClick = export.onShare, + modifier = Modifier.testTag("media_share_button"), + ) { + Icon( + imageVector = Icons.Filled.Share, + contentDescription = stringResource(R.string.image_viewer_share), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt new file mode 100644 index 00000000..654fa690 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt @@ -0,0 +1,217 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.Intent +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.m57.hermescontrol.ExternalActivityLifecycleGuard +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.notification.NotificationHelper +import com.m57.hermescontrol.ui.chat.MediaExportHelper +import com.m57.hermescontrol.ui.chat.MediaSaveStrategy +import com.m57.hermescontrol.ui.chat.acceptedSaveDestination +import com.m57.hermescontrol.ui.chat.mediaSaveStrategy +import com.m57.hermescontrol.ui.chat.normalizeFrameworkMime +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Save/Share state for the media viewer; [isBusy] is true while either is in flight. */ +internal class MediaExportActions( + val isBusy: Boolean, + val onSave: () -> Unit, + val onShare: () -> Unit, +) + +/** + * The single Save/Share pipeline for every media kind (issue #1328). Streams + * through [MediaExportHelper]: MediaStore Downloads on API 29+, a SAF + * create-document picker on API 26-28, and a FileProvider share cache. + */ +@Composable +internal fun rememberMediaExportActions( + mediaUri: String, + mimeType: String?, + displayName: String, +): MediaExportActions { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isBusy by remember { mutableStateOf(false) } + val saveFailed = stringResource(R.string.media_player_save_failed) + val shareFailed = stringResource(R.string.media_player_share_failed) + val shareTitle = stringResource(R.string.media_player_share_title) + + val launchExternalActivity: (() -> Unit) -> Unit = { launch -> + ExternalActivityLifecycleGuard.launchExternalActivity( + acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, + releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease, + prepareForBackground = { NotificationHelper.start(context) }, + cleanupAfterLaunchFailure = { NotificationHelper.stop(context) }, + launch = launch, + ) + } + + val saveDocumentLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + try { + ExternalActivityLifecycleGuard.externalActivityReturned() + val destination = acceptedSaveDestination(result.resultCode, result.data?.data) + if (destination != null) { + scope.launch { + try { + val msg = + MediaExportHelper.saveMediaToUri( + context = context, + sourceUri = mediaUri, + targetUri = destination, + fallbackMime = + mimeType?.takeUnless { it == "application/octet-stream" } + ?: com.m57.hermescontrol.ui.chat + .mediaMimeForPath(displayName), + ) + withContext(Dispatchers.Main) { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() + } + } finally { + isBusy = false + } + } + } else { + isBusy = false + } + } catch (e: Throwable) { + isBusy = false + if (e is CancellationException) throw e + } + } + + val onSave: () -> Unit = { + if (!isBusy) { + isBusy = true + when (mediaSaveStrategy()) { + MediaSaveStrategy.MEDIA_STORE -> { + scope.launch { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val msg = + MediaExportHelper.saveMediaToDownloads( + context = context, + uri = mediaUri, + fallbackMime = + mimeType?.takeUnless { it == "application/octet-stream" } + ?: com.m57.hermescontrol.ui.chat + .mediaMimeForPath(displayName), + displayName = displayName, + ) + withContext(Dispatchers.Main) { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() + } + } finally { + isBusy = false + } + } + } + + MediaSaveStrategy.CREATE_DOCUMENT -> { + val suggestedName = + MediaExportHelper.resolveDisplayName( + displayName = displayName, + mimeType = mimeType, + uri = mediaUri, + ) + val rawMime = + mimeType?.takeUnless { it == "application/octet-stream" } + ?: com.m57.hermescontrol.ui.chat + .mediaMimeForPath(displayName) + val normalizedMime = normalizeFrameworkMime(rawMime) + val intent = + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = normalizedMime + putExtra(Intent.EXTRA_TITLE, suggestedName) + } + try { + launchExternalActivity { + saveDocumentLauncher.launch(intent) + } + } catch (_: Exception) { + isBusy = false + scope.launch(Dispatchers.Main) { + Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() + } + } + } + } + } + } + + val onShare: () -> Unit = { + if (!isBusy) { + isBusy = true + scope.launch { + try { + val intent = + MediaExportHelper.shareMedia( + context = context, + uri = mediaUri, + fallbackMime = + mimeType?.takeUnless { it == "application/octet-stream" } + ?: com.m57.hermescontrol.ui.chat + .mediaMimeForPath(displayName), + displayName = displayName, + ) + withContext(Dispatchers.Main) { + if (intent != null) { + val chooser = Intent.createChooser(intent, shareTitle) + try { + launchExternalActivity { + context.startActivity(chooser) + } + } catch (_: Exception) { + Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() + } + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() + } + } finally { + isBusy = false + } + } + } + } + + return MediaExportActions(isBusy = isBusy, onSave = onSave, onShare = onShare) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt index b8666b08..41110577 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt @@ -1,11 +1,6 @@ package com.m57.hermescontrol.ui.chat.components -import android.content.Intent import android.net.Uri -import android.os.Build -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.OptIn import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -51,7 +46,6 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -76,28 +70,18 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView -import com.m57.hermescontrol.ExternalActivityLifecycleGuard import com.m57.hermescontrol.R import com.m57.hermescontrol.data.remote.MediaDataSourceProvider -import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.theme.LocalHermesStatusColors -import com.m57.hermescontrol.ui.chat.MediaExportHelper import com.m57.hermescontrol.ui.chat.MediaKind -import com.m57.hermescontrol.ui.chat.MediaSaveStrategy -import com.m57.hermescontrol.ui.chat.acceptedSaveDestination import com.m57.hermescontrol.ui.chat.classifyMedia import com.m57.hermescontrol.ui.chat.mediaNameFromPath -import com.m57.hermescontrol.ui.chat.mediaSaveStrategy -import com.m57.hermescontrol.ui.chat.normalizeFrameworkMime -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext /** - * Unified Media Viewer Dialog for both Audio and Video, backed by AndroidX Media3 ExoPlayer. + * The one full-screen media viewer (issue #1328): images get the zoom/pan + * surface, audio and video the ExoPlayer surface. Both share the same streaming + * Save/Share pipeline ([rememberMediaExportActions]). */ @OptIn(UnstableApi::class) @Composable @@ -108,7 +92,12 @@ fun MediaViewerDialog( mimeType: String? = null, ) { key(mediaUri, mimeType) { - MediaViewerContent(mediaUri, onDismissRequest, title, mimeType) + val kind = remember { classifyMedia(mimeType = mimeType, name = title, uri = mediaUri) } + if (kind == MediaKind.IMAGE) { + ImageViewerContent(mediaUri, onDismissRequest, title, mimeType) + } else { + MediaViewerContent(mediaUri, onDismissRequest, title, mimeType) + } } } @@ -122,7 +111,6 @@ private fun MediaViewerContent( ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - val scope = rememberCoroutineScope() val mediaKind = remember(mediaUri, mimeType, title) { @@ -145,12 +133,8 @@ private fun MediaViewerContent( var dragPositionMs by remember { mutableLongStateOf(0L) } var showControls by remember { mutableStateOf(true) } - var isBusy by remember { mutableStateOf(false) } val statusColors = LocalHermesStatusColors.current - val saveFailed = stringResource(R.string.media_player_save_failed) - val shareFailed = stringResource(R.string.media_player_share_failed) - val shareTitle = stringResource(R.string.media_player_share_title) val player = remember(context, mediaUri) { @@ -260,166 +244,10 @@ private fun MediaViewerContent( } } - val launchExternalActivity: (() -> Unit) -> Unit = { launch -> - ExternalActivityLifecycleGuard.launchExternalActivity( - acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, - releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease, - prepareForBackground = { NotificationHelper.start(context) }, - cleanupAfterLaunchFailure = { NotificationHelper.stop(context) }, - launch = launch, - ) - } - - val saveDocumentLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - ) { result -> - try { - ExternalActivityLifecycleGuard.externalActivityReturned() - val destination = acceptedSaveDestination(result.resultCode, result.data?.data) - if (destination != null) { - scope.launch { - try { - val msg = - MediaExportHelper.saveMediaToUri( - context = context, - sourceUri = mediaUri, - targetUri = destination, - fallbackMime = - mimeType?.takeUnless { it == "application/octet-stream" } - ?: com.m57.hermescontrol.ui.chat - .mediaMimeForPath(displayName), - ) - withContext(Dispatchers.Main) { - Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() - } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() - } - } finally { - isBusy = false - } - } - } else { - isBusy = false - } - } catch (e: Throwable) { - isBusy = false - if (e is CancellationException) throw e - } - } - - val onSave: () -> Unit = { - if (!isBusy) { - isBusy = true - when (mediaSaveStrategy()) { - MediaSaveStrategy.MEDIA_STORE -> { - scope.launch { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val msg = - MediaExportHelper.saveMediaToDownloads( - context = context, - uri = mediaUri, - fallbackMime = - mimeType?.takeUnless { it == "application/octet-stream" } - ?: com.m57.hermescontrol.ui.chat - .mediaMimeForPath(displayName), - displayName = displayName, - ) - withContext(Dispatchers.Main) { - Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() - } - } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() - } - } finally { - isBusy = false - } - } - } - - MediaSaveStrategy.CREATE_DOCUMENT -> { - val suggestedName = - MediaExportHelper.resolveDisplayName( - displayName = displayName, - mimeType = mimeType, - uri = mediaUri, - ) - val rawMime = - mimeType?.takeUnless { it == "application/octet-stream" } - ?: com.m57.hermescontrol.ui.chat - .mediaMimeForPath(displayName) - val normalizedMime = normalizeFrameworkMime(rawMime) - val intent = - Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = normalizedMime - putExtra(Intent.EXTRA_TITLE, suggestedName) - } - try { - launchExternalActivity { - saveDocumentLauncher.launch(intent) - } - } catch (_: Exception) { - isBusy = false - scope.launch(Dispatchers.Main) { - Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() - } - } - } - } - } - } - - val onShare: () -> Unit = { - if (!isBusy) { - isBusy = true - scope.launch { - try { - val intent = - MediaExportHelper.shareMedia( - context = context, - uri = mediaUri, - fallbackMime = - mimeType?.takeUnless { it == "application/octet-stream" } - ?: com.m57.hermescontrol.ui.chat - .mediaMimeForPath(displayName), - displayName = displayName, - ) - withContext(Dispatchers.Main) { - if (intent != null) { - val chooser = Intent.createChooser(intent, shareTitle) - try { - launchExternalActivity { - context.startActivity(chooser) - } - } catch (_: Exception) { - Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() - } - } else { - Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() - } - } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() - } - } finally { - isBusy = false - } - } - } - } + val exportActions = rememberMediaExportActions(mediaUri, mimeType, displayName) + val isBusy = exportActions.isBusy + val onSave = exportActions.onSave + val onShare = exportActions.onShare Dialog( onDismissRequest = onDismissRequest, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 15e5b90e..6ff8169f 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1261,7 +1261,6 @@ إغلاق حفظ على الجهاز مشاركة -مشاركة الصورة إدارة المزود… لا توجد إعدادات للمزود لا يحتوي هذا المزود على خيارات قابلة للتهيئة. @@ -1277,10 +1276,6 @@ مفتاح API (اتركه فارغاً للإبقاء عليه) مفعل معطل -تم حفظ الصورة في التنزيلات -تعذر حفظ الصورة -تعذر تحميل الصورة: %1$s -تعذر مشاركة الصورة المكونات الأكثر استخداماً لا تتوفر بيانات للنماذج لا تتوفر بيانات للمهارات diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index c659080f..395700da 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1082,11 +1082,6 @@ 닫기 기기에 저장 공유 - 이미지 공유 - 다운로드에 이미지 저장됨 - 이미지를 저장할 수 없음 - 이미지를 불러올 수 없음: %1$s - 이미지를 공유할 수 없음 스킬 호출됨 시스템 알림 도구 호출 한도 도달 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index eac2130b..34212136 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1223,7 +1223,6 @@ 关闭 保存到设备 分享 - 分享图片 管理提供商… 没有提供商配置 此提供商没有可配置的界面。 @@ -1239,10 +1238,6 @@ API 密钥(留空保持不变) 开 关 - 图片已保存到下载目录 - 无法保存图片 - 无法加载图片:%1$s - 无法分享图片 %1$d 处发现 %1$d 处发现 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c05a133d..793ce2ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1469,7 +1469,6 @@ Close Save to device Share - Share image Manage provider… No provider config This provider has no configurable surface. @@ -1485,10 +1484,6 @@ API key (leave blank to keep) On Off - Image saved to Downloads - Could not save image - Could not load image: %1$s - Could not share image Most Used Components No model data available diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt deleted file mode 100644 index b01aff74..00000000 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -import android.content.Context -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.util.Base64 - -@OptIn(ExperimentalCoroutinesApi::class) -class ImageBytesResolverTest { - private val context = mockk(relaxed = true) - - @Before - fun setUp() { - // resolve() runs on Dispatchers.IO; bind the main dispatcher for tests. - Dispatchers.setMain(StandardTestDispatcher()) - } - - @Test - fun `extensionForMime maps known types`() { - assertEquals("png", ImageBytesResolver.extensionForMime("image/png")) - assertEquals("png", ImageBytesResolver.extensionForMime(" image/png; charset=binary")) - assertEquals("jpg", ImageBytesResolver.extensionForMime("image/jpeg")) - assertEquals("jpg", ImageBytesResolver.extensionForMime("image/JPG")) - assertEquals("gif", ImageBytesResolver.extensionForMime("image/gif")) - assertEquals("webp", ImageBytesResolver.extensionForMime("image/webp")) - assertEquals("heic", ImageBytesResolver.extensionForMime("image/heic")) - assertEquals("svg", ImageBytesResolver.extensionForMime("image/svg+xml")) - assertEquals("img", ImageBytesResolver.extensionForMime("image/unknown")) - assertEquals("heic", ImageBytesResolver.extensionForMime("image/heif")) - assertEquals("bmp", ImageBytesResolver.extensionForMime("image/bmp")) - // Non-image content types stay `.img`, not `.bin`/`.pdf`. - assertEquals("img", ImageBytesResolver.extensionForMime("application/pdf")) - assertEquals("img", ImageBytesResolver.extensionForMime("text/html")) - } - - @Test - fun `data URL base64 decodes to bytes with mime and extension`() = - runTest { - val raw = "hello-image".toByteArray() - val b64 = Base64.getEncoder().encodeToString(raw) - val model = "data:image/png;base64,$b64" - - val result = ImageBytesResolver.resolve(context, model, "image/*") - - assertTrue(result is ImageBytesResolver.Result.Bytes) - result as ImageBytesResolver.Result.Bytes - assertArrayEquals(raw, result.bytes) - assertEquals("image/png", result.mimeType) - assertEquals("png", result.extension) - } - - @Test - fun `data URL falls back to fallbackMime when meta omitted`() = - runTest { - val raw = byteArrayOf(1, 2, 3, 4) - val b64 = Base64.getEncoder().encodeToString(raw) - val model = "data:;base64,$b64" - - val result = ImageBytesResolver.resolve(context, model, "image/webp") - - assertTrue(result is ImageBytesResolver.Result.Bytes) - result as ImageBytesResolver.Result.Bytes - assertEquals("image/webp", result.mimeType) - assertEquals("webp", result.extension) - } - - @Test - fun `malformed data URL (no comma) returns Error`() = - runTest { - val result = ImageBytesResolver.resolve(context, "data:image/png;base64XXXX", "image/*") - assertTrue(result is ImageBytesResolver.Result.Error) - } - - @Test - fun `non-base64 data URL returns Error`() = - runTest { - val result = ImageBytesResolver.resolve(context, "data:image/png,notbase64", "image/*") - assertTrue(result is ImageBytesResolver.Result.Error) - } - - @Test - fun `unsupported model source returns Error`() = - runTest { - val result = ImageBytesResolver.resolve(context, "file:///sdcard/x.png", "image/*") - assertTrue(result is ImageBytesResolver.Result.Error) - } -} From cc010b451ae566e63f9575ef6aeaa0bf83a9f22d Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:39:17 +0300 Subject: [PATCH 047/107] refactor(chat): unify tool catalog and prewarm payloads (#1326, #1327) (#1356) --- .../hermescontrol/ui/chat/ChatViewModel.kt | 22 ++ .../m57/hermescontrol/ui/chat/ToolBubble.kt | 3 +- .../ui/chat/ToolSchemaRegistry.kt | 281 +----------------- .../hermescontrol/ui/chat/tool/ToolCatalog.kt | 144 +++++++++ .../ui/chat/tool/ToolRendererRegistry.kt | 107 +------ .../ui/chat/tool/ToolViewCache.kt | 61 ++++ .../ui/chat/tool/ToolCatalogTest.kt | 53 ++++ .../ui/chat/tool/ToolViewCacheTest.kt | 51 ++++ 8 files changed, 339 insertions(+), 383 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalog.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCache.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalogTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCacheTest.kt 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 465e4356..8a0d9c8e 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 @@ -40,6 +40,7 @@ import com.m57.hermescontrol.data.ws.toAny import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.notification.captureTurnBoundary import com.m57.hermescontrol.notification.correlationScopeId +import com.m57.hermescontrol.ui.chat.tool.ToolViewCache import com.m57.hermescontrol.ui.common.ActionProgressController import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -51,6 +52,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -734,6 +738,24 @@ class ChatViewModel( _uiState.value, ) + init { + // Parse tool payloads off the main thread before the transcript + // composes them (issue #1327); ToolBubble then reads a cache hit. + viewModelScope.launch(searchDispatcher) { + _uiState + .map { it.messages } + .distinctUntilChanged() + .conflate() + .collect { messages -> + for (message in messages) { + if (message.role == MessageRole.TOOL) { + ToolViewCache.prewarm(message.content, message.toolName, message.isToolRunning) + } + } + } + } + } + /** * Session ID to resume when the WebSocket connects. Set synchronously by * [ChatScreen] via `SideEffect` during composition — before any WS event 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 54946d42..67901cdf 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 @@ -73,6 +73,7 @@ import com.m57.hermescontrol.ui.chat.components.DiffViewCard import com.m57.hermescontrol.ui.chat.components.FileViewCard import com.m57.hermescontrol.ui.chat.tool.ToolView import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder +import com.m57.hermescontrol.ui.chat.tool.ToolViewCache import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -133,7 +134,7 @@ internal fun ToolBubble( val view = remember(message.content, message.toolName, message.isToolRunning) { - parseToolOutput(message.content, message.toolName, message.isToolRunning) + ToolViewCache.getOrParse(message.content, message.toolName, message.isToolRunning) } val config = ToolSchemaRegistry.getDisplayConfig(message.toolName) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt index 22afc91d..7a61217c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt @@ -1,292 +1,19 @@ package com.m57.hermescontrol.ui.chat import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.MenuBook -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.AccountTree -import androidx.compose.material.icons.filled.AutoStories import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.ChatBubble -import androidx.compose.material.icons.filled.Checklist -import androidx.compose.material.icons.filled.Computer -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Forum -import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.Keyboard -import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.Memory -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.Photo -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Psychology -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Terminal -import androidx.compose.material.icons.filled.ThumbUp -import androidx.compose.material.icons.filled.TouchApp -import androidx.compose.material.icons.filled.Visibility import androidx.compose.ui.graphics.vector.ImageVector +import com.m57.hermescontrol.ui.chat.tool.ToolCatalog -/** - * Display configuration for a Hermes tool — controls the summary line, - * icon, and which arg field is shown when the bubble is collapsed. - * - * Based on the canonical tool schemas defined in the Hermes Agent source - * at /opt/hermes-agent/tools/ (each tool has a `*_SCHEMA` dict). - */ +/** Display configuration for a Hermes tool — its name and header icon. */ data class ToolDisplayConfig( val name: String, /** Material icon for this tool type. */ val icon: ImageVector = Icons.Filled.Build, ) -/** Maps tool names to their display config. Ordered by expected frequency of use. */ +/** Icon lookup backed by the single [ToolCatalog] (issue #1326). */ object ToolSchemaRegistry { - private val knownTools: Map = - mapOf( - "terminal" to - ToolDisplayConfig( - name = "terminal", - icon = Icons.Filled.Terminal, - ), - "read_file" to - ToolDisplayConfig( - name = "read_file", - icon = Icons.Filled.Description, - ), - "write_file" to - ToolDisplayConfig( - name = "write_file", - icon = Icons.Filled.Edit, - ), - "patch" to - ToolDisplayConfig( - name = "patch", - icon = Icons.Filled.Build, - ), - "search_files" to - ToolDisplayConfig( - name = "search_files", - icon = Icons.Filled.Search, - ), - "web_search" to - ToolDisplayConfig( - name = "web_search", - icon = Icons.Filled.Public, - ), - "browser_navigate" to - ToolDisplayConfig( - name = "browser_navigate", - icon = Icons.Filled.Language, - ), - "browser_click" to - ToolDisplayConfig( - name = "browser_click", - icon = Icons.Filled.TouchApp, - ), - "browser_snapshot" to - ToolDisplayConfig( - name = "browser_snapshot", - icon = Icons.Filled.Photo, - ), - "clarify" to - ToolDisplayConfig( - name = "clarify", - icon = Icons.Filled.ChatBubble, - ), - "delegate_task" to - ToolDisplayConfig( - name = "delegate_task", - icon = Icons.Filled.AccountTree, - ), - "execute_code" to - ToolDisplayConfig( - name = "execute_code", - icon = Icons.Filled.PlayArrow, - ), - "todo" to - ToolDisplayConfig( - name = "todo", - icon = Icons.Filled.Checklist, - ), - "todo_list" to - ToolDisplayConfig( - name = "todo_list", - icon = Icons.Filled.Checklist, - ), - "fact_store" to - ToolDisplayConfig( - name = "fact_store", - icon = Icons.Filled.Psychology, - ), - "session_search" to - ToolDisplayConfig( - name = "session_search", - icon = Icons.Filled.Search, - ), - // ── Action-based tools ── - "cronjob" to - ToolDisplayConfig( - name = "cronjob", - icon = Icons.Filled.Schedule, - ), - "cronjob_manage" to - ToolDisplayConfig( - name = "cronjob_manage", - icon = Icons.Filled.Schedule, - ), - "memory" to - ToolDisplayConfig( - name = "memory", - icon = Icons.Filled.Memory, - ), - "fact_feedback" to - ToolDisplayConfig( - name = "fact_feedback", - icon = Icons.Filled.ThumbUp, - ), - "process" to - ToolDisplayConfig( - name = "process", - icon = Icons.Filled.Settings, - ), - "process_manage" to - ToolDisplayConfig( - name = "process_manage", - icon = Icons.Filled.Settings, - ), - "skill_manage" to - ToolDisplayConfig( - name = "skill_manage", - icon = Icons.Filled.Build, - ), - // ── Web / Browser tools ── - "web_extract" to - ToolDisplayConfig( - name = "web_extract", - icon = Icons.Filled.Language, - ), - "browser_type" to - ToolDisplayConfig( - name = "browser_type", - icon = Icons.Filled.Keyboard, - ), - "browser_scroll" to - ToolDisplayConfig( - name = "browser_scroll", - icon = Icons.Filled.TouchApp, - ), - "browser_back" to - ToolDisplayConfig( - name = "browser_back", - icon = Icons.Filled.Language, - ), - "browser_press" to - ToolDisplayConfig( - name = "browser_press", - icon = Icons.Filled.Keyboard, - ), - "browser_get_images" to - ToolDisplayConfig( - name = "browser_get_images", - icon = Icons.Filled.Image, - ), - "browser_vision" to - ToolDisplayConfig( - name = "browser_vision", - icon = Icons.Filled.Visibility, - ), - "browser_console" to - ToolDisplayConfig( - name = "browser_console", - icon = Icons.Filled.Terminal, - ), - "browser_cdp" to - ToolDisplayConfig( - name = "browser_cdp", - icon = Icons.Filled.Build, - ), - "browser_dialog" to - ToolDisplayConfig( - name = "browser_dialog", - icon = Icons.Filled.ChatBubble, - ), - // ── Media / Vision tools ── - "vision_analyze" to - ToolDisplayConfig( - name = "vision_analyze", - icon = Icons.Filled.Visibility, - ), - "text_to_speech" to - ToolDisplayConfig( - name = "text_to_speech", - icon = Icons.AutoMirrored.Filled.VolumeUp, - ), - "video_generate" to - ToolDisplayConfig( - name = "video_generate", - icon = Icons.Filled.Movie, - ), - "image_generate" to - ToolDisplayConfig( - name = "image_generate", - icon = Icons.Filled.Image, - ), - // ── Social / Messaging tools ── - "x_search" to - ToolDisplayConfig( - name = "x_search", - icon = Icons.Filled.Forum, - ), - "send_message" to - ToolDisplayConfig( - name = "send_message", - icon = Icons.AutoMirrored.Filled.Send, - ), - // ── Skills tools ── - "skills_list" to - ToolDisplayConfig( - name = "skills_list", - icon = Icons.AutoMirrored.Filled.MenuBook, - ), - "skill_view" to - ToolDisplayConfig( - name = "skill_view", - icon = Icons.Filled.AutoStories, - ), - // ── On-demand tools ── - "tool_search" to - ToolDisplayConfig( - name = "tool_search", - icon = Icons.Filled.Search, - ), - "tool_describe" to - ToolDisplayConfig( - name = "tool_describe", - icon = Icons.Filled.AutoStories, - ), - "tool_call" to - ToolDisplayConfig( - name = "tool_call", - icon = Icons.Filled.Build, - ), - // ── Misc ── - "read_terminal" to - ToolDisplayConfig( - name = "read_terminal", - icon = Icons.Filled.Computer, - ), - "computer_use" to - ToolDisplayConfig( - name = "computer_use", - icon = Icons.Filled.Computer, - ), - ) - fun getDisplayConfig(toolName: String?): ToolDisplayConfig = - toolName?.let { knownTools[it] } ?: ToolDisplayConfig(name = toolName ?: "tool") + ToolDisplayConfig(name = toolName ?: "tool", icon = ToolCatalog.definitionFor(toolName).icon) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalog.kt new file mode 100644 index 00000000..2e6e25f0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalog.kt @@ -0,0 +1,144 @@ +package com.m57.hermescontrol.ui.chat.tool + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.AutoStories +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.Movie +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.ui.graphics.vector.ImageVector +import com.m57.hermescontrol.ui.chat.tool.render.BrowserActionRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserConsoleRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserImagesRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserNavigateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserVisionRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ComputerUseRenderer +import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer +import com.m57.hermescontrol.ui.chat.tool.render.DelegateTaskRenderer +import com.m57.hermescontrol.ui.chat.tool.render.FactStoreRenderer +import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ImageGenerateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.MemoryRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProcessRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SearchFilesRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SessionSearchRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SkillViewRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer +import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer +import com.m57.hermescontrol.ui.chat.tool.render.TextToSpeechRenderer +import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ToolDescribeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer +import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer +import com.m57.hermescontrol.ui.chat.tool.render.WebSearchRenderer +import com.m57.hermescontrol.ui.chat.tool.render.XSearchRenderer + +/** A tool's visual identity (header/status icon) and its [ToolRenderer]. */ +internal data class ToolDefinition( + val icon: ImageVector, + val renderer: ToolRenderer, +) + +/** All-default renderer: defers everything to the generic fallbacks. */ +internal object GenericToolRenderer : ToolRenderer + +/** + * The single tool catalog (issue #1326): every known tool declares its icon + * and renderer together, so the two can no longer drift apart. + * + * Unknown tools get the Build icon and [GenericToolRenderer]: every hook + * returns null, so [ToolViewBuilder] falls back to the humanized tool name + * and the [ToolResultSummary] heuristics — never a raw JSON dump. + */ +internal object ToolCatalog { + val FALLBACK = ToolDefinition(Icons.Filled.Build, GenericToolRenderer) + + val entries: Map = + mapOf( + "browser_back" to ToolDefinition(Icons.Filled.Language, BrowserActionRenderer), + "browser_cdp" to ToolDefinition(Icons.Filled.Build, GenericToolRenderer), + "browser_click" to ToolDefinition(Icons.Filled.TouchApp, BrowserClickRenderer), + "browser_console" to ToolDefinition(Icons.Filled.Terminal, BrowserConsoleRenderer), + "browser_dialog" to ToolDefinition(Icons.Filled.ChatBubble, GenericToolRenderer), + "browser_fill" to ToolDefinition(Icons.Filled.Keyboard, BrowserTypeRenderer), + "browser_get_images" to ToolDefinition(Icons.Filled.Image, BrowserImagesRenderer), + "browser_navigate" to ToolDefinition(Icons.Filled.Language, BrowserNavigateRenderer), + "browser_press" to ToolDefinition(Icons.Filled.Keyboard, BrowserActionRenderer), + "browser_scroll" to ToolDefinition(Icons.Filled.TouchApp, BrowserActionRenderer), + "browser_snapshot" to ToolDefinition(Icons.Filled.Photo, BrowserSnapshotRenderer), + "browser_type" to ToolDefinition(Icons.Filled.Keyboard, BrowserTypeRenderer), + "browser_vision" to ToolDefinition(Icons.Filled.Visibility, BrowserVisionRenderer), + "clarify" to ToolDefinition(Icons.Filled.ChatBubble, GenericToolRenderer), + "computer_use" to ToolDefinition(Icons.Filled.Computer, ComputerUseRenderer), + "cronjob" to ToolDefinition(Icons.Filled.Schedule, CronjobRenderer), + "cronjob_manage" to ToolDefinition(Icons.Filled.Schedule, CronjobRenderer), + "delegate_task" to ToolDefinition(Icons.Filled.AccountTree, DelegateTaskRenderer), + "edit_file" to ToolDefinition(Icons.Filled.Edit, FileEditRenderer), + "execute_code" to ToolDefinition(Icons.Filled.PlayArrow, TerminalRenderer), + "fact_feedback" to ToolDefinition(Icons.Filled.ThumbUp, GenericToolRenderer), + "fact_store" to ToolDefinition(Icons.Filled.Psychology, FactStoreRenderer), + "image_generate" to ToolDefinition(Icons.Filled.Image, ImageGenerateRenderer), + "memory" to ToolDefinition(Icons.Filled.Memory, MemoryRenderer), + "patch" to ToolDefinition(Icons.Filled.Build, FileEditRenderer), + "process" to ToolDefinition(Icons.Filled.Settings, ProcessRenderer), + "process_manage" to ToolDefinition(Icons.Filled.Settings, ProcessRenderer), + "project_create" to ToolDefinition(Icons.Filled.AccountTree, ProjectMutateRenderer), + "project_list" to ToolDefinition(Icons.Filled.AccountTree, ProjectListRenderer), + "project_switch" to ToolDefinition(Icons.Filled.AccountTree, ProjectMutateRenderer), + "read_file" to ToolDefinition(Icons.Filled.Description, ReadFileRenderer), + "read_terminal" to ToolDefinition(Icons.Filled.Computer, ReadTerminalRenderer), + "search_files" to ToolDefinition(Icons.Filled.Search, SearchFilesRenderer), + "send_message" to ToolDefinition(Icons.AutoMirrored.Filled.Send, GenericToolRenderer), + "session_search" to ToolDefinition(Icons.Filled.Search, SessionSearchRenderer), + "skill_manage" to ToolDefinition(Icons.Filled.Build, SkillManageRenderer), + "skill_view" to ToolDefinition(Icons.Filled.AutoStories, SkillViewRenderer), + "skills_list" to ToolDefinition(Icons.AutoMirrored.Filled.MenuBook, SkillsListRenderer), + "terminal" to ToolDefinition(Icons.Filled.Terminal, TerminalRenderer), + "text_to_speech" to ToolDefinition(Icons.AutoMirrored.Filled.VolumeUp, TextToSpeechRenderer), + "todo" to ToolDefinition(Icons.Filled.Checklist, TodoRenderer), + "todo_list" to ToolDefinition(Icons.Filled.Checklist, TodoRenderer), + "tool_call" to ToolDefinition(Icons.Filled.Build, GenericToolRenderer), + "tool_describe" to ToolDefinition(Icons.Filled.AutoStories, ToolDescribeRenderer), + "tool_search" to ToolDefinition(Icons.Filled.Search, ToolSearchRenderer), + "video_generate" to ToolDefinition(Icons.Filled.Movie, GenericToolRenderer), + "vision_analyze" to ToolDefinition(Icons.Filled.Visibility, VisionAnalyzeRenderer), + "web_extract" to ToolDefinition(Icons.Filled.Language, WebExtractRenderer), + "web_search" to ToolDefinition(Icons.Filled.Public, WebSearchRenderer), + "write_file" to ToolDefinition(Icons.Filled.Edit, FileEditRenderer), + "x_search" to ToolDefinition(Icons.Filled.Forum, XSearchRenderer), + ) + + fun definitionFor(toolName: String?): ToolDefinition = toolName?.let { entries[it] } ?: FALLBACK +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt index 1d06f7f1..9e443595 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt @@ -1,109 +1,6 @@ package com.m57.hermescontrol.ui.chat.tool -import com.m57.hermescontrol.ui.chat.tool.render.BrowserActionRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserConsoleRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserImagesRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserNavigateRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserVisionRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ComputerUseRenderer -import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer -import com.m57.hermescontrol.ui.chat.tool.render.DelegateTaskRenderer -import com.m57.hermescontrol.ui.chat.tool.render.FactStoreRenderer -import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ImageGenerateRenderer -import com.m57.hermescontrol.ui.chat.tool.render.MemoryRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ProcessRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer -import com.m57.hermescontrol.ui.chat.tool.render.SearchFilesRenderer -import com.m57.hermescontrol.ui.chat.tool.render.SessionSearchRenderer -import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer -import com.m57.hermescontrol.ui.chat.tool.render.SkillViewRenderer -import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer -import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer -import com.m57.hermescontrol.ui.chat.tool.render.TextToSpeechRenderer -import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ToolDescribeRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer -import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer -import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer -import com.m57.hermescontrol.ui.chat.tool.render.WebSearchRenderer -import com.m57.hermescontrol.ui.chat.tool.render.XSearchRenderer - -/** - * Maps tool names to their display strategy. - * - * Unknown tools get [GenericToolRenderer]: every hook returns null, so - * [ToolViewBuilder] falls back to the humanized tool name and the - * [ToolResultSummary] heuristics — never a raw JSON dump. - */ +/** Renderer lookup backed by the single [ToolCatalog]. */ internal object ToolRendererRegistry { - /** All-default renderer: defers everything to the generic fallbacks. */ - private object GenericToolRenderer : ToolRenderer - - private val renderers: Map = - mapOf( - // Shell - "terminal" to TerminalRenderer, - "execute_code" to TerminalRenderer, - "read_terminal" to ReadTerminalRenderer, - // Delegation - "delegate_task" to DelegateTaskRenderer, - // Files - "read_file" to ReadFileRenderer, - "edit_file" to FileEditRenderer, - "patch" to FileEditRenderer, - "write_file" to FileEditRenderer, - "search_files" to SearchFilesRenderer, - // Web - "web_search" to WebSearchRenderer, - "web_extract" to WebExtractRenderer, - "x_search" to XSearchRenderer, - // Browser - "browser_navigate" to BrowserNavigateRenderer, - "browser_snapshot" to BrowserSnapshotRenderer, - "browser_click" to BrowserClickRenderer, - "browser_fill" to BrowserTypeRenderer, - "browser_type" to BrowserTypeRenderer, - "browser_scroll" to BrowserActionRenderer, - "browser_back" to BrowserActionRenderer, - "browser_press" to BrowserActionRenderer, - "browser_get_images" to BrowserImagesRenderer, - "browser_vision" to BrowserVisionRenderer, - "browser_console" to BrowserConsoleRenderer, - // Memory / facts - "memory" to MemoryRenderer, - "fact_store" to FactStoreRenderer, - // Task management - "cronjob" to CronjobRenderer, - "cronjob_manage" to CronjobRenderer, - "todo" to TodoRenderer, - "todo_list" to TodoRenderer, - "session_search" to SessionSearchRenderer, - "process" to ProcessRenderer, - "process_manage" to ProcessRenderer, - // Skills / tool discovery - "skills_list" to SkillsListRenderer, - "skill_view" to SkillViewRenderer, - "skill_manage" to SkillManageRenderer, - "tool_search" to ToolSearchRenderer, - "tool_describe" to ToolDescribeRenderer, - // Media - "image_generate" to ImageGenerateRenderer, - "vision_analyze" to VisionAnalyzeRenderer, - "text_to_speech" to TextToSpeechRenderer, - // Projects - "project_list" to ProjectListRenderer, - "project_create" to ProjectMutateRenderer, - "project_switch" to ProjectMutateRenderer, - // Desktop automation - "computer_use" to ComputerUseRenderer, - ) - - fun rendererFor(toolName: String): ToolRenderer = renderers[toolName] ?: GenericToolRenderer + fun rendererFor(toolName: String): ToolRenderer = ToolCatalog.definitionFor(toolName).renderer } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCache.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCache.kt new file mode 100644 index 00000000..4b000a9d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCache.kt @@ -0,0 +1,61 @@ +package com.m57.hermescontrol.ui.chat.tool + +import com.m57.hermescontrol.ui.chat.parseToolOutput + +/** + * Bounded memo of parsed tool payloads (issue #1327). + * + * The ViewModel [prewarm]s tool rows on a background dispatcher whenever the + * transcript changes, so [ToolBubble] normally gets a cache hit during + * composition instead of parsing JSON on the main thread. A miss (e.g. a live + * event that lands before prewarm finishes) parses synchronously exactly like + * before, so the rendered result — and its height — never changes. + * + * Keyed by the payload itself (not its hash), so a hit is always the view the + * same inputs would produce. + */ +internal object ToolViewCache { + private const val MAX_ENTRIES = 512 + + private data class Key( + val content: String, + val toolName: String?, + val isRunning: Boolean, + ) + + /** Boxes the result so a null parse (non-JSON content) is cached too. */ + private class Entry( + val view: ToolView?, + ) + + private val entries = + object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > MAX_ENTRIES + } + + fun getOrParse( + content: String, + toolName: String?, + isRunning: Boolean, + ): ToolView? { + val key = Key(content, toolName, isRunning) + synchronized(entries) { entries[key] }?.let { return it.view } + val view = parseToolOutput(content, toolName, isRunning) + synchronized(entries) { entries[key] = Entry(view) } + return view + } + + fun prewarm( + content: String, + toolName: String?, + isRunning: Boolean, + ) { + getOrParse(content, toolName, isRunning) + } + + internal fun size(): Int = synchronized(entries) { entries.size } + + internal fun clear() { + synchronized(entries) { entries.clear() } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalogTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalogTest.kt new file mode 100644 index 00000000..ad2af493 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalogTest.kt @@ -0,0 +1,53 @@ +package com.m57.hermescontrol.ui.chat.tool + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Terminal +import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry +import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer +import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class ToolCatalogTest { + @Test + fun `icon and renderer lookups read the same catalog entry`() { + for ((name, def) in ToolCatalog.entries) { + assertSame(name, def.icon, ToolSchemaRegistry.getDisplayConfig(name).icon) + assertSame(name, def.renderer, ToolRendererRegistry.rendererFor(name)) + assertEquals(name, ToolSchemaRegistry.getDisplayConfig(name).name) + } + } + + @Test + fun `tools that drifted now have a real icon`() { + assertSame(Icons.Filled.Edit, ToolSchemaRegistry.getDisplayConfig("edit_file").icon) + assertSame(Icons.Filled.Keyboard, ToolSchemaRegistry.getDisplayConfig("browser_fill").icon) + for (name in listOf("project_list", "project_create", "project_switch")) { + assertSame(name, Icons.Filled.AccountTree, ToolSchemaRegistry.getDisplayConfig(name).icon) + } + } + + @Test + fun `known tools keep their renderers and icons`() { + assertSame(TerminalRenderer, ToolRendererRegistry.rendererFor("terminal")) + assertSame(Icons.Filled.Terminal, ToolSchemaRegistry.getDisplayConfig("terminal").icon) + assertSame(FileEditRenderer, ToolRendererRegistry.rendererFor("edit_file")) + assertSame(BrowserTypeRenderer, ToolRendererRegistry.rendererFor("browser_fill")) + // Icon-only tools still render generically. + assertSame(GenericToolRenderer, ToolRendererRegistry.rendererFor("clarify")) + } + + @Test + fun `unknown tools fall back to build icon and generic renderer`() { + assertSame(Icons.Filled.Build, ToolSchemaRegistry.getDisplayConfig("mystery").icon) + assertEquals("mystery", ToolSchemaRegistry.getDisplayConfig("mystery").name) + assertEquals("tool", ToolSchemaRegistry.getDisplayConfig(null).name) + assertSame(GenericToolRenderer, ToolRendererRegistry.rendererFor("mystery")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCacheTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCacheTest.kt new file mode 100644 index 00000000..14163bc1 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCacheTest.kt @@ -0,0 +1,51 @@ +package com.m57.hermescontrol.ui.chat.tool + +import com.m57.hermescontrol.ui.chat.parseToolOutput +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test + +class ToolViewCacheTest { + private val payload = """{"name":"terminal","args":{"command":"ls"},"result":{"output":"a","exit_code":0}}""" + + @Before + fun reset() = ToolViewCache.clear() + + @Test + fun `cached view equals a fresh parse`() { + val cached = ToolViewCache.getOrParse(payload, "terminal", false) + assertEquals(parseToolOutput(payload, "terminal", false), cached) + } + + @Test + fun `prewarm makes composition read a hit`() { + ToolViewCache.prewarm(payload, "terminal", false) + val first = ToolViewCache.getOrParse(payload, "terminal", false) + assertSame(first, ToolViewCache.getOrParse(payload, "terminal", false)) + assertEquals(1, ToolViewCache.size()) + } + + @Test + fun `running state and content changes are separate entries`() { + val running = ToolViewCache.getOrParse(payload, "terminal", true) + val done = ToolViewCache.getOrParse(payload, "terminal", false) + assertNotSame(running, done) + assertEquals(2, ToolViewCache.size()) + } + + @Test + fun `non json content is cached as null`() { + assertNull(ToolViewCache.getOrParse("plain text", "terminal", false)) + assertNull(ToolViewCache.getOrParse("plain text", "terminal", false)) + assertEquals(1, ToolViewCache.size()) + } + + @Test + fun `cache is bounded`() { + repeat(600) { i -> ToolViewCache.getOrParse("""{"i":$i}""", "terminal", false) } + assertEquals(512, ToolViewCache.size()) + } +} From 76726dd0860f86908f03ea005ec3cf13e9a867b2 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:30:05 +0300 Subject: [PATCH 048/107] refactor(chat): isolate transcript and browser return state (#1320, #1321) (#1357) --- .../hermescontrol/ui/chat/ChatScreenTest.kt | 9 ++ .../chat/fullbleed/FullBleedChatAnchorTest.kt | 61 ++++++-- .../chat/fullbleed/FullBleedChatListTest.kt | 53 ++++--- .../chat/fullbleed/TranscriptTestActions.kt | 21 +++ .../ui/chat/BrowserReturnTracker.kt | 44 ++++++ .../ui/chat/ChatConnectorsViewModel.kt | 11 ++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 143 ++++++------------ .../hermescontrol/ui/chat/ChatViewModel.kt | 21 +++ .../ui/chat/fullbleed/FullBleedChatList.kt | 110 ++++++-------- .../ui/chat/fullbleed/TranscriptUiState.kt | 99 ++++++++++++ .../ui/chat/BrowserReturnTrackerTest.kt | 62 ++++++++ .../chat/fullbleed/TranscriptUiStateTest.kt | 88 +++++++++++ 12 files changed, 521 insertions(+), 201 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptTestActions.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/BrowserReturnTracker.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/BrowserReturnTrackerTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiStateTest.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 52dd188f..564742ac 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 @@ -11,6 +11,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptUiState import com.m57.hermescontrol.ui.common.ActionProgressController import io.mockk.every import io.mockk.mockk @@ -61,6 +62,10 @@ class ChatScreenTest { every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow() every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow() every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow() + every { mockViewModel.transcriptState } returns + MutableStateFlow( + TranscriptUiState.resolve(uiState, ChatTimelineState(), StreamingState(), null, null), + ).asStateFlow() every { mockViewModel.connectionOperationState } returns MutableStateFlow(ConnectionOperationUiState()).asStateFlow() // ActionProgressDialog collects the controller's StateFlow — a relaxed @@ -110,6 +115,10 @@ class ChatScreenTest { every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow() every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow() every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow() + every { mockViewModel.transcriptState } returns + MutableStateFlow( + TranscriptUiState.resolve(uiState, ChatTimelineState(), StreamingState(), null, null), + ).asStateFlow() every { mockViewModel.connectionOperationState } returns MutableStateFlow(ConnectionOperationUiState()).asStateFlow() every { mockViewModel.actionProgress } returns diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt index 58d09b5c..5a940cee 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt @@ -12,7 +12,8 @@ import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatSearchState -import com.m57.hermescontrol.ui.chat.ChatViewModel +import com.m57.hermescontrol.ui.chat.ChatTimelineState +import com.m57.hermescontrol.ui.chat.ChatUiState import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.StreamingState @@ -21,7 +22,6 @@ import com.m57.hermescontrol.ui.chat.VaultCodePromptUi import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController -import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch @@ -116,29 +116,58 @@ class FullBleedChatAnchorTest { val tailKeys = listOf("agent_status", "clarify_bubble", "vault_unlock_card", "vault_save_login_card", "vault_code_card") val listState = LazyListState(index, offset) - val viewModel = mockk(relaxed = true) lateinit var scope: CoroutineScope compose.setContent { scope = rememberCoroutineScope() val controller = rememberChatScrollController(listState, scope) Box(Modifier.size(width = 320.dp, height = if (tailKey == null) 400.dp else 24.dp)) { FullBleedChatList( - messages = messages.value, - streamingState = streaming.value, - isAgentTyping = tailKey != null, + transcript = + TranscriptUiState.resolve( + chat = + ChatUiState( + messages = messages.value, + isAgentTyping = tailKey != null, + typingEffectEnabled = false, + typingEffectDelayMs = 30, + isLoadingOlder = loading.value, + currentSessionId = session.value, + clarifyRequest = + if (tailKey != + null + ) { + ClarifyUi("Choose an option", listOf("Yes")) + } else { + null + }, + vaultUnlockPrompt = + if (tailKey != null) { + VaultUnlockPromptUi( + null, + null, + ) + } else { + null + }, + vaultSaveLoginPrompt = + if (tailKey != + null + ) { + VaultSaveLoginPromptUi(null, null) + } else { + null + }, + vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null, + ), + timeline = ChatTimelineState(), + streaming = streaming.value, + savingAttachmentPath = null, + speakingMessageId = null, + ), + actions = testTranscriptActions(), searchState = ChatSearchState(), - typingEffectEnabled = false, - typingEffectDelayMs = 30, - isLoading = false, - isLoadingOlder = loading.value, listState = listState, scrollController = controller, - viewModel = viewModel, - pagingSessionId = session.value, - clarifyRequest = if (tailKey != null) ClarifyUi("Choose an option", listOf("Yes")) else null, - vaultUnlockPrompt = if (tailKey != null) VaultUnlockPromptUi(null, null) else null, - vaultSaveLoginPrompt = if (tailKey != null) VaultSaveLoginPromptUi(null, null) else null, - vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null, ) } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt index b55870f2..a40a1c10 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt @@ -17,12 +17,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatSearchState -import com.m57.hermescontrol.ui.chat.ChatViewModel +import com.m57.hermescontrol.ui.chat.ChatTimelineState +import com.m57.hermescontrol.ui.chat.ChatUiState import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.ToolStatus import com.m57.hermescontrol.ui.chat.components.ChatScrollController -import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import org.junit.Rule @@ -76,34 +76,41 @@ class FullBleedChatListTest { composeTestRule.setContent { val listState = LazyListState() FullBleedChatList( - messages = messages, - streamingState = streamingState, - isAgentTyping = isAgentTyping, + transcript = + TranscriptUiState.resolve( + chat = + ChatUiState( + messages = messages, + isAgentTyping = isAgentTyping, + typingEffectEnabled = false, + typingEffectDelayMs = 30, + messageStatsEnabled = messageStatsEnabled, + showUserMessageTokens = showUserMessageTokens, + showAssistantMessageTokens = showAssistantMessageTokens, + showTokensPerSecond = showTokensPerSecond, + clarifyRequest = + if (clarify) { + com.m57.hermescontrol.ui.chat.ClarifyUi( + text = "pick", + options = listOf("a"), + ) + } else { + null + }, + ), + timeline = ChatTimelineState(), + streaming = streamingState, + savingAttachmentPath = null, + speakingMessageId = null, + ), + actions = testTranscriptActions(), searchState = ChatSearchState(), - typingEffectEnabled = false, - typingEffectDelayMs = 30, - messageStatsEnabled = messageStatsEnabled, - showUserMessageTokens = showUserMessageTokens, - showAssistantMessageTokens = showAssistantMessageTokens, - showTokensPerSecond = showTokensPerSecond, - isLoading = false, - isLoadingOlder = false, listState = listState, scrollController = ChatScrollController( listState = listState, scope = CoroutineScope(Dispatchers.Main.immediate), ), - viewModel = mockk(relaxed = true), - clarifyRequest = - if (clarify) { - com.m57.hermescontrol.ui.chat.ClarifyUi( - text = "pick", - options = listOf("a"), - ) - } else { - null - }, ) } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptTestActions.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptTestActions.kt new file mode 100644 index 00000000..90f29583 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptTestActions.kt @@ -0,0 +1,21 @@ +package com.m57.hermescontrol.ui.chat.fullbleed + +/** No-op actions for transcript layout tests; callbacks are irrelevant to their assertions. */ +internal fun testTranscriptActions() = + TranscriptActions( + onLoadOlder = {}, + onOpenAttachment = {}, + onSaveAttachment = {}, + onImageClick = {}, + onRespondApproval = {}, + onRespondClarify = {}, + onRespondClarifyBatch = {}, + onDismissClarify = {}, + onRespondVaultUnlock = {}, + onDismissVaultUnlock = {}, + onRespondVaultSaveLogin = { _, _ -> }, + onDismissVaultSaveLogin = {}, + onRespondVaultCode = {}, + onDismissVaultCode = {}, + onToggleSpeak = {}, + ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/BrowserReturnTracker.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/BrowserReturnTracker.kt new file mode 100644 index 00000000..da12f70c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/BrowserReturnTracker.kt @@ -0,0 +1,44 @@ +package com.m57.hermescontrol.ui.chat + +/** Tracks a launched browser across pause/resume and screen disposal (issue #1321). */ +internal class BrowserReturnTracker { + data class Returned( + val operationId: String?, + ) + + private var inFlight = false + private var departed = false + private var operationId: String? = null + + fun start(operationId: String? = null) { + this.operationId = operationId + inFlight = true + departed = false + } + + fun onPause() { + if (inFlight) departed = true + } + + /** Only an actual departure followed by resume counts as returning from the browser. */ + fun onResume(): Returned? { + if (!inFlight || !departed) return null + val returned = Returned(operationId) + cancel() + return returned + } + + /** Drop the tracking after a failed launch; the lifecycle guard handles the failed lease. */ + fun cancel() { + inFlight = false + departed = false + operationId = null + } + + /** Non-configuration disposal must release an outstanding departed activity lease. */ + fun abandon(): Boolean { + val outstanding = inFlight && departed + cancel() + return outstanding + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt index 6aff6dab..f3052293 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt @@ -28,6 +28,17 @@ class ChatConnectorsViewModel( ) val uiState: StateFlow = delegateInstance.uiState + private val browserReturnTracker = BrowserReturnTracker() + + fun browserLaunched() = browserReturnTracker.start() + + fun browserLaunchFailed() = browserReturnTracker.cancel() + + fun browserPaused() = browserReturnTracker.onPause() + + fun browserReturned(): Boolean = browserReturnTracker.onResume() != null + + fun abandonBrowser(): Boolean = browserReturnTracker.abandon() init { var lastContext: Triple? = 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 919ec93e..6f5baa49 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 @@ -123,6 +123,7 @@ import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList +import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptActions import com.m57.hermescontrol.ui.common.ActionProgressDialog import com.m57.hermescontrol.ui.common.AutoScrollingTitleText import com.m57.hermescontrol.ui.common.CredentialWarningBanner @@ -168,6 +169,7 @@ fun ChatScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() val streamingState by viewModel.streamingState.collectAsStateWithLifecycle() val timelineState by viewModel.timelineState.collectAsStateWithLifecycle() + val transcriptState by viewModel.transcriptState.collectAsStateWithLifecycle() val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle() val connectorsViewModel: ChatConnectorsViewModel = viewModel() val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle() @@ -181,42 +183,26 @@ fun ChatScreen( val displayedMessages = timelineState.historyMessages ?: state.messages val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current - var browserAuthInFlight by rememberSaveable { mutableStateOf(false) } - var browserAuthDeparted by rememberSaveable { mutableStateOf(false) } - var connectionBrowserOperationId by remember { mutableStateOf(null) } - var connectionBrowserDeparted by remember { mutableStateOf(false) } DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP -> { - if (browserAuthInFlight) { - browserAuthDeparted = true - } - if (connectionBrowserOperationId != null) { - connectionBrowserDeparted = true - } + connectorsViewModel.browserPaused() + viewModel.connectionBrowserPaused() connectorsViewModel.onPause() } Lifecycle.Event.ON_RESUME -> { viewModel.refreshSettings() connectorsViewModel.onResume() - val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted - val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted } + val legacyBrowserReturned = connectorsViewModel.browserReturned() + val operationId = viewModel.connectionBrowserReturned() if (legacyBrowserReturned || operationId != null) { ExternalActivityLifecycleGuard.externalActivityReturned() } - if (legacyBrowserReturned) { - browserAuthInFlight = false - browserAuthDeparted = false - } - if (operationId != null) { - connectionBrowserOperationId = null - connectionBrowserDeparted = false - viewModel.wakeConnectionOperation(operationId) - } + operationId?.let(viewModel::wakeConnectionOperation) } else -> {} @@ -231,18 +217,11 @@ fun ChatScreen( .firstOrNull() val isChangingConfigs = activity?.isChangingConfigurations == true if (!isChangingConfigs) { - val externalActivityOutstanding = - (browserAuthInFlight && browserAuthDeparted) || - (connectionBrowserOperationId != null && connectionBrowserDeparted) - if (externalActivityOutstanding) { + val legacyOutstanding = connectorsViewModel.abandonBrowser() + val connectionOutstanding = viewModel.abandonConnectionBrowser() + if (legacyOutstanding || connectionOutstanding) { ExternalActivityLifecycleGuard.externalActivityReturned() } - if (browserAuthInFlight && browserAuthDeparted) { - browserAuthInFlight = false - browserAuthDeparted = false - } - connectionBrowserOperationId = null - connectionBrowserDeparted = false connectorsViewModel.onPause() connectorsViewModel.hide() } @@ -413,21 +392,17 @@ fun ChatScreen( addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } launchExternalActivity { - browserAuthDeparted = false - browserAuthInFlight = true + connectorsViewModel.browserLaunched() context.startActivity(intent) } } catch (_: ActivityNotFoundException) { - browserAuthInFlight = false - browserAuthDeparted = false + connectorsViewModel.browserLaunchFailed() connectorsViewModel.launchError(browserLaunchError) } catch (_: SecurityException) { - browserAuthInFlight = false - browserAuthDeparted = false + connectorsViewModel.browserLaunchFailed() connectorsViewModel.launchError(browserLaunchError) } catch (_: Exception) { - browserAuthInFlight = false - browserAuthDeparted = false + connectorsViewModel.browserLaunchFailed() connectorsViewModel.launchError(browserLaunchError) } } @@ -759,61 +734,41 @@ fun ChatScreen( // Full-bleed chat renderer (issue #866) — the single chat // surface since the bubble renderer was removed. FullBleedChatList( - messages = displayedMessages, - streamingState = if (timelineState.isHistorical) StreamingState() else streamingState, - isAgentTyping = state.isAgentTyping && !timelineState.isHistorical, + transcript = + transcriptState.copy( + savingAttachmentPath = pendingSavePath ?: transcriptState.savingAttachmentPath, + speakingMessageId = speakingMessageId, + ), + actions = + TranscriptActions( + onLoadOlder = viewModel::loadOlderMessages, + onOpenAttachment = viewModel::openAttachment, + onSaveAttachment = onSaveAttachment, + onImageClick = { viewingImage = it }, + onRespondApproval = viewModel::respondToApproval, + onRespondClarify = viewModel::respondToClarify, + onRespondClarifyBatch = viewModel::respondToClarifyBatch, + onDismissClarify = viewModel::dismissClarify, + onRespondVaultUnlock = viewModel::respondToVaultUnlock, + onDismissVaultUnlock = viewModel::dismissVaultUnlock, + onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin, + onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin, + onRespondVaultCode = viewModel::respondToVaultCode, + onDismissVaultCode = viewModel::dismissVaultCode, + onToggleSpeak = { message -> + val scopeKey = "${dataScope?.inMemoryKey(localKey = activeSessionId ?: "none")}" + speechController.toggle( + SpeechRequest( + scopeKey = scopeKey, + messageId = message.id, + text = SpeechText.stripMarkdownForSpeech(message.content), + ), + ) + }, + ), searchState = searchState, - typingEffectEnabled = state.typingEffectEnabled && !timelineState.isHistorical, - typingEffectDelayMs = state.typingEffectDelayMs, - messageStatsEnabled = state.messageStatsEnabled, - showUserMessageTokens = state.showUserMessageTokens, - showAssistantMessageTokens = state.showAssistantMessageTokens, - showTokensPerSecond = state.showTokensPerSecond, - maxToolCallsPerTurn = state.maxToolCallsPerTurn, - isLoading = state.isLoading && !timelineState.isHistorical, - isLoadingOlder = state.isLoadingOlder && !timelineState.isHistorical, - hasOlderMessages = state.hasOlderMessages && !timelineState.isHistorical, - pagingSessionId = - state.currentSessionId?.let { currentSessionId -> - if (timelineState.isHistorical) { - currentSessionId + ":history:" + timelineState.historyAnchorRowId - } else { - currentSessionId - } - }, listState = listState, scrollController = scrollController, - viewModel = viewModel, - clarifyRequest = state.clarifyRequest.takeUnless { timelineState.isHistorical }, - onRespondClarify = viewModel::respondToClarify, - onRespondClarifyBatch = viewModel::respondToClarifyBatch, - onDismissClarify = viewModel::dismissClarify, - vaultUnlockPrompt = state.vaultUnlockPrompt.takeUnless { timelineState.isHistorical }, - onRespondVaultUnlock = viewModel::respondToVaultUnlock, - onDismissVaultUnlock = viewModel::dismissVaultUnlock, - vaultSaveLoginPrompt = state.vaultSaveLoginPrompt.takeUnless { timelineState.isHistorical }, - onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin, - onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin, - vaultCodePrompt = state.vaultCodePrompt.takeUnless { timelineState.isHistorical }, - onRespondVaultCode = viewModel::respondToVaultCode, - onDismissVaultCode = viewModel::dismissVaultCode, - onSaveAttachment = onSaveAttachment, - savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath, - openingAttachmentPath = state.openingAttachmentPath, - isCompressing = state.isCompressing && !timelineState.isHistorical, - compressionStatus = state.compressionStatus.takeUnless { timelineState.isHistorical }, - onImageClick = { viewingImage = it }, - speakingMessageId = speakingMessageId, - onToggleSpeak = { message -> - val scopeKey = "${dataScope?.inMemoryKey(localKey = activeSessionId ?: "none")}" - speechController.toggle( - SpeechRequest( - scopeKey = scopeKey, - messageId = message.id, - text = SpeechText.stripMarkdownForSpeech(message.content), - ), - ) - }, replyErrorContent = state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure -> { @@ -1084,8 +1039,7 @@ fun ChatScreen( if (ConnectorUrlValidator.isValidHttpsUrl(url)) { try { launchExternalActivity { - connectionBrowserOperationId = operationId - connectionBrowserDeparted = false + viewModel.connectionBrowserLaunched(operationId) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -1093,8 +1047,7 @@ fun ChatScreen( context.startActivity(intent) } } catch (_: Exception) { - connectionBrowserOperationId = null - connectionBrowserDeparted = false + viewModel.connectionBrowserLaunchFailed() scrollScope.launch { snackbarHostState.showSnackbar(browserLaunchError) } } } 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 8a0d9c8e..69b4a1c4 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 @@ -40,6 +40,7 @@ import com.m57.hermescontrol.data.ws.toAny import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.notification.captureTurnBoundary import com.m57.hermescontrol.notification.correlationScopeId +import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptUiState import com.m57.hermescontrol.ui.chat.tool.ToolViewCache import com.m57.hermescontrol.ui.common.ActionProgressController import kotlinx.coroutines.CancellationException @@ -452,6 +453,17 @@ class ChatViewModel( // ── Internal state ─────────────────────────────────────────────────── private val _uiState = MutableStateFlow(ChatUiState()) val connectionOperationState: StateFlow = connectionOperationDelegate.state + private val connectionBrowserReturnTracker = BrowserReturnTracker() + + fun connectionBrowserLaunched(operationId: String) = connectionBrowserReturnTracker.start(operationId) + + fun connectionBrowserLaunchFailed() = connectionBrowserReturnTracker.cancel() + + fun connectionBrowserPaused() = connectionBrowserReturnTracker.onPause() + + fun connectionBrowserReturned(): String? = connectionBrowserReturnTracker.onResume()?.operationId + + fun abandonConnectionBrowser(): Boolean = connectionBrowserReturnTracker.abandon() private val _streamingState = MutableStateFlow(StreamingState()) @@ -738,6 +750,15 @@ class ChatViewModel( _uiState.value, ) + val transcriptState: StateFlow = + combine(uiState, timelineState, streamingState) { chat, timeline, streaming -> + TranscriptUiState.resolve(chat, timeline, streaming, savingAttachmentPath = null, speakingMessageId = null) + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + TranscriptUiState.resolve(_uiState.value, _timelineState.value, _streamingState.value, null, null), + ) + init { // Parse tool payloads off the main thread before the transcript // composes them (issue #1327); ToolBubble then reads a cache hit. 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 1db9f185..998facc2 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 @@ -34,15 +34,9 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalChatFontScale import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatSearchState -import com.m57.hermescontrol.ui.chat.ChatViewModel -import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ImageViewerModel -import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.ToolCallDivider import com.m57.hermescontrol.ui.chat.UserBubble -import com.m57.hermescontrol.ui.chat.VaultCodePromptUi -import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi -import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch import com.m57.hermescontrol.ui.chat.components.ChatScrollController import com.m57.hermescontrol.ui.chat.components.ClarifyBubble @@ -78,48 +72,36 @@ private object FullBleedContentType { */ @Composable fun FullBleedChatList( - messages: List, - streamingState: StreamingState, - isAgentTyping: Boolean, + transcript: TranscriptUiState, + actions: TranscriptActions, searchState: ChatSearchState, - typingEffectEnabled: Boolean, - typingEffectDelayMs: Int, - messageStatsEnabled: Boolean = false, - showUserMessageTokens: Boolean = true, - showAssistantMessageTokens: Boolean = true, - showTokensPerSecond: Boolean = true, - maxToolCallsPerTurn: Int? = null, - isLoading: Boolean, - isLoadingOlder: Boolean, listState: androidx.compose.foundation.lazy.LazyListState, scrollController: ChatScrollController, - viewModel: ChatViewModel, - clarifyRequest: ClarifyUi? = null, - onRespondClarify: ((String) -> Unit)? = null, - onRespondClarifyBatch: ((Map) -> Unit)? = null, - onDismissClarify: (() -> Unit)? = null, - vaultUnlockPrompt: VaultUnlockPromptUi? = null, - onRespondVaultUnlock: ((String) -> Unit)? = null, - onDismissVaultUnlock: (() -> Unit)? = null, - vaultSaveLoginPrompt: VaultSaveLoginPromptUi? = null, - onRespondVaultSaveLogin: ((String, String) -> Unit)? = null, - onDismissVaultSaveLogin: (() -> Unit)? = null, - vaultCodePrompt: VaultCodePromptUi? = null, - onRespondVaultCode: ((String) -> Unit)? = null, - onDismissVaultCode: (() -> Unit)? = null, - onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {}, - savingAttachmentPath: String? = null, - openingAttachmentPath: String? = null, - onImageClick: (ImageViewerModel) -> Unit = {}, - hasOlderMessages: Boolean = false, - pagingSessionId: String? = null, - onLoadOlder: () -> Unit = viewModel::loadOlderMessages, replyErrorContent: (@Composable () -> Unit)? = null, - speakingMessageId: String? = null, - onToggleSpeak: ((ChatMessage) -> Unit)? = null, - isCompressing: Boolean = false, - compressionStatus: String? = null, ) { + val messages = transcript.messages + val streamingState = transcript.streamingState + val isAgentTyping = transcript.isAgentTyping + val typingEffectEnabled = transcript.typingEffectEnabled + val typingEffectDelayMs = transcript.typingEffectDelayMs + val messageStatsEnabled = transcript.messageStatsEnabled + val showUserMessageTokens = transcript.showUserMessageTokens + val showAssistantMessageTokens = transcript.showAssistantMessageTokens + val showTokensPerSecond = transcript.showTokensPerSecond + val maxToolCallsPerTurn = transcript.maxToolCallsPerTurn + val isLoading = transcript.isLoading + val isLoadingOlder = transcript.isLoadingOlder + val hasOlderMessages = transcript.hasOlderMessages + val pagingSessionId = transcript.pagingSessionId + val clarifyRequest = transcript.clarifyRequest + val vaultUnlockPrompt = transcript.vaultUnlockPrompt + val vaultSaveLoginPrompt = transcript.vaultSaveLoginPrompt + val vaultCodePrompt = transcript.vaultCodePrompt + val savingAttachmentPath = transcript.savingAttachmentPath + val openingAttachmentPath = transcript.openingAttachmentPath + val isCompressing = transcript.isCompressing + val compressionStatus = transcript.compressionStatus + val speakingMessageId = transcript.speakingMessageId if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) { Box( modifier = Modifier.fillMaxSize(), @@ -189,9 +171,9 @@ fun FullBleedChatList( put("clarify_bubble") { ClarifyBubble( clarifyRequest = clarifyRequest, - onRespondSingle = { option -> onRespondClarify?.invoke(option) }, - onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) }, - onDismiss = { onDismissClarify?.invoke() }, + onRespondSingle = actions.onRespondClarify, + onRespondBatch = actions.onRespondClarifyBatch, + onDismiss = actions.onDismissClarify, ) } } @@ -200,8 +182,8 @@ fun FullBleedChatList( put("vault_unlock_card") { VaultUnlockCard( prompt = vaultUnlockPrompt, - onConfirm = { password -> onRespondVaultUnlock?.invoke(password) }, - onDismiss = { onDismissVaultUnlock?.invoke() }, + onConfirm = actions.onRespondVaultUnlock, + onDismiss = actions.onDismissVaultUnlock, ) } } @@ -210,10 +192,8 @@ fun FullBleedChatList( put("vault_save_login_card") { VaultSaveLoginCard( prompt = vaultSaveLoginPrompt, - onConfirm = { identifier, password -> - onRespondVaultSaveLogin?.invoke(identifier, password) - }, - onDismiss = { onDismissVaultSaveLogin?.invoke() }, + onConfirm = actions.onRespondVaultSaveLogin, + onDismiss = actions.onDismissVaultSaveLogin, ) } } @@ -222,8 +202,8 @@ fun FullBleedChatList( put("vault_code_card") { VaultCodeCard( prompt = vaultCodePrompt, - onConfirm = { code -> onRespondVaultCode?.invoke(code) }, - onDismiss = { onDismissVaultCode?.invoke() }, + onConfirm = actions.onRespondVaultCode, + onDismiss = actions.onDismissVaultCode, ) } } @@ -249,7 +229,7 @@ fun FullBleedChatList( } val prefetch = remember(pagingSessionId) { ChatHistoryPrefetch() } val canLoad = rememberUpdatedState(hasOlderMessages && !isLoadingOlder && !isLoading) - val loadOlder = rememberUpdatedState(onLoadOlder) + val loadOlder = rememberUpdatedState(actions.onLoadOlder) val prefetchConnection = remember(listState, prefetch, scrollController) { object : NestedScrollConnection { @@ -338,11 +318,11 @@ fun FullBleedChatList( isCurrentMatch = searchState.currentMatchId != null && searchState.currentMatchId == userMessage.id, - onOpenAttachment = viewModel::openAttachment, - onSaveAttachment = onSaveAttachment, + onOpenAttachment = actions.onOpenAttachment, + onSaveAttachment = actions.onSaveAttachment, savingAttachmentPath = savingAttachmentPath, openingAttachmentPath = openingAttachmentPath, - onImageClick = onImageClick, + onImageClick = actions.onImageClick, messageStatsEnabled = messageStatsEnabled, showUserMessageTokens = showUserMessageTokens, ) @@ -410,11 +390,7 @@ fun FullBleedChatList( speakingMessageId != null && speakingMessageId == proseMessage.id val toggleSpeakAction = - if (onToggleSpeak != null) { - { onToggleSpeak(proseMessage) } - } else { - null - } + { actions.onToggleSpeak(proseMessage) } FullBleedAgentMessage( message = proseMessage, // Highlight only bubbles that actually contain a match — @@ -431,12 +407,12 @@ fun FullBleedChatList( searchState.currentMatchId != null && searchState.currentMatchId == proseMessage.id, showReasoning = !hoistedReasoning, - onOpenAttachment = viewModel::openAttachment, - onSaveAttachment = onSaveAttachment, + onOpenAttachment = actions.onOpenAttachment, + onSaveAttachment = actions.onSaveAttachment, savingAttachmentPath = savingAttachmentPath, openingAttachmentPath = openingAttachmentPath, canSaveAttachment = savingAttachmentPath == null, - onImageClick = onImageClick, + onImageClick = actions.onImageClick, isSpeaking = isSpeakingThis, onToggleSpeak = toggleSpeakAction, messageStatsEnabled = messageStatsEnabled, @@ -480,7 +456,7 @@ fun FullBleedChatList( } else { FullBleedSystemEvent( message = sysMessage, - onRespondApproval = viewModel::respondToApproval, + onRespondApproval = actions.onRespondApproval, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt new file mode 100644 index 00000000..f02686fa --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt @@ -0,0 +1,99 @@ +package com.m57.hermescontrol.ui.chat.fullbleed + +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.ChatTimelineState +import com.m57.hermescontrol.ui.chat.ChatUiState +import com.m57.hermescontrol.ui.chat.ClarifyUi +import com.m57.hermescontrol.ui.chat.ImageViewerModel +import com.m57.hermescontrol.ui.chat.StreamingState +import com.m57.hermescontrol.ui.chat.VaultCodePromptUi +import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi +import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi + +/** The transcript's resolved, read-only state; the list never receives the whole chat ViewModel. */ +data class TranscriptUiState( + val messages: List, + val streamingState: StreamingState, + val isAgentTyping: Boolean, + val typingEffectEnabled: Boolean, + val typingEffectDelayMs: Int, + val messageStatsEnabled: Boolean, + val showUserMessageTokens: Boolean, + val showAssistantMessageTokens: Boolean, + val showTokensPerSecond: Boolean, + val maxToolCallsPerTurn: Int?, + val isLoading: Boolean, + val isLoadingOlder: Boolean, + val hasOlderMessages: Boolean, + val pagingSessionId: String?, + val clarifyRequest: ClarifyUi?, + val vaultUnlockPrompt: VaultUnlockPromptUi?, + val vaultSaveLoginPrompt: VaultSaveLoginPromptUi?, + val vaultCodePrompt: VaultCodePromptUi?, + val savingAttachmentPath: String?, + val openingAttachmentPath: String?, + val isCompressing: Boolean, + val compressionStatus: String?, + val speakingMessageId: String?, +) { + companion object { + /** Resolve historical versus live mode at the state boundary, not in the screen call. */ + fun resolve( + chat: ChatUiState, + timeline: ChatTimelineState, + streaming: StreamingState, + savingAttachmentPath: String?, + speakingMessageId: String?, + ): TranscriptUiState { + val historical = timeline.isHistorical + return TranscriptUiState( + messages = timeline.historyMessages ?: chat.messages, + streamingState = if (historical) StreamingState() else streaming, + isAgentTyping = !historical && chat.isAgentTyping, + typingEffectEnabled = !historical && chat.typingEffectEnabled, + typingEffectDelayMs = chat.typingEffectDelayMs, + messageStatsEnabled = chat.messageStatsEnabled, + showUserMessageTokens = chat.showUserMessageTokens, + showAssistantMessageTokens = chat.showAssistantMessageTokens, + showTokensPerSecond = chat.showTokensPerSecond, + maxToolCallsPerTurn = chat.maxToolCallsPerTurn, + isLoading = !historical && chat.isLoading, + isLoadingOlder = !historical && chat.isLoadingOlder, + hasOlderMessages = !historical && chat.hasOlderMessages, + pagingSessionId = + chat.currentSessionId?.let { id -> + if (historical) "$id:history:${timeline.historyAnchorRowId}" else id + }, + clarifyRequest = chat.clarifyRequest.takeUnless { historical }, + vaultUnlockPrompt = chat.vaultUnlockPrompt.takeUnless { historical }, + vaultSaveLoginPrompt = chat.vaultSaveLoginPrompt.takeUnless { historical }, + vaultCodePrompt = chat.vaultCodePrompt.takeUnless { historical }, + savingAttachmentPath = savingAttachmentPath ?: chat.savingAttachmentPath, + openingAttachmentPath = chat.openingAttachmentPath, + isCompressing = !historical && chat.isCompressing, + compressionStatus = chat.compressionStatus.takeUnless { historical }, + speakingMessageId = speakingMessageId, + ) + } + } +} + +/** All effects the transcript can request. The UI never reaches through to [ChatUiState]'s owner. */ +class TranscriptActions( + val onLoadOlder: () -> Unit, + val onOpenAttachment: (Attachment) -> Unit, + val onSaveAttachment: (Attachment) -> Unit, + val onImageClick: (ImageViewerModel) -> Unit, + val onRespondApproval: (String) -> Unit, + val onRespondClarify: (String) -> Unit, + val onRespondClarifyBatch: (Map) -> Unit, + val onDismissClarify: () -> Unit, + val onRespondVaultUnlock: (String) -> Unit, + val onDismissVaultUnlock: () -> Unit, + val onRespondVaultSaveLogin: (String, String) -> Unit, + val onDismissVaultSaveLogin: () -> Unit, + val onRespondVaultCode: (String) -> Unit, + val onDismissVaultCode: () -> Unit, + val onToggleSpeak: (ChatMessage) -> Unit, +) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/BrowserReturnTrackerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/BrowserReturnTrackerTest.kt new file mode 100644 index 00000000..31f1d62c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/BrowserReturnTrackerTest.kt @@ -0,0 +1,62 @@ +package com.m57.hermescontrol.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BrowserReturnTrackerTest { + @Test + fun `browser return requires a departure and consumes the lease once`() { + val tracker = BrowserReturnTracker() + tracker.start("operation-1") + assertNull(tracker.onResume()) + tracker.onPause() + assertEquals("operation-1", tracker.onResume()?.operationId) + assertNull(tracker.onResume()) + assertFalse(tracker.abandon()) + } + + @Test + fun `legacy browser without operation id still reports return`() { + val tracker = BrowserReturnTracker() + tracker.start() + tracker.onPause() + assertTrue(tracker.onResume() != null) + assertNull(tracker.onResume()) + } + + @Test + fun `failed launch clears tracking and does not release a departed lease`() { + val tracker = BrowserReturnTracker() + tracker.start("operation-1") + tracker.onPause() + tracker.cancel() + assertNull(tracker.onResume()) + assertFalse(tracker.abandon()) + } + + @Test + fun `non configuration disposal releases only a departed lease and clears the id`() { + val tracker = BrowserReturnTracker() + tracker.start("operation-1") + assertFalse(tracker.abandon()) + tracker.start("operation-2") + tracker.onPause() + assertTrue(tracker.abandon()) + assertFalse(tracker.abandon()) + assertNull(tracker.onResume()) + } + + @Test + fun `new launch resets departed state from the previous launch`() { + val tracker = BrowserReturnTracker() + tracker.start("first") + tracker.onPause() + tracker.start("second") + assertNull(tracker.onResume()) + tracker.onPause() + assertEquals("second", tracker.onResume()?.operationId) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiStateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiStateTest.kt new file mode 100644 index 00000000..5aef459d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiStateTest.kt @@ -0,0 +1,88 @@ +package com.m57.hermescontrol.ui.chat.fullbleed + +import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.ChatTimelineState +import com.m57.hermescontrol.ui.chat.ChatUiState +import com.m57.hermescontrol.ui.chat.ClarifyUi +import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.StreamingState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class TranscriptUiStateTest { + private val live = ChatMessage(id = "live", role = MessageRole.USER, content = "hello") + private val historical = ChatMessage(id = "historical", role = MessageRole.USER, content = "older") + private val clarify = ClarifyUi(text = "choose") + + @Test + fun `live state preserves prompts streaming and paging actions`() { + val stream = StreamingState() + val result = + TranscriptUiState.resolve( + chat = + ChatUiState( + messages = listOf(live), + currentSessionId = "session", + isAgentTyping = true, + isLoading = true, + isLoadingOlder = true, + hasOlderMessages = true, + clarifyRequest = clarify, + isCompressing = true, + savingAttachmentPath = "server-path", + ), + timeline = ChatTimelineState(), + streaming = stream, + savingAttachmentPath = "local-path", + speakingMessageId = "live", + ) + assertEquals(listOf(live), result.messages) + assertSame(stream, result.streamingState) + assertEquals("session", result.pagingSessionId) + assertTrue(result.isAgentTyping) + assertTrue(result.isLoading) + assertTrue(result.isLoadingOlder) + assertTrue(result.hasOlderMessages) + assertSame(clarify, result.clarifyRequest) + assertTrue(result.isCompressing) + assertEquals("local-path", result.savingAttachmentPath) + assertEquals("live", result.speakingMessageId) + } + + @Test + fun `historical state isolates live tail prompts loading and compression`() { + val result = + TranscriptUiState.resolve( + chat = + ChatUiState( + messages = listOf(live), + currentSessionId = "session", + isAgentTyping = true, + isLoading = true, + isLoadingOlder = true, + hasOlderMessages = true, + clarifyRequest = clarify, + isCompressing = true, + compressionStatus = "compressing", + ), + timeline = ChatTimelineState(historyMessages = listOf(historical), historyAnchorRowId = 42), + streaming = StreamingState(streamingMessage = live), + savingAttachmentPath = null, + speakingMessageId = null, + ) + assertEquals(listOf(historical), result.messages) + assertNull(result.streamingState.streamingMessage) + assertEquals("session:history:42", result.pagingSessionId) + assertFalse(result.isAgentTyping) + assertFalse(result.isLoading) + assertFalse(result.isLoadingOlder) + assertFalse(result.hasOlderMessages) + assertNull(result.clarifyRequest) + assertFalse(result.isCompressing) + assertNull(result.compressionStatus) + } +} From 58794bee00f044bc7356503d00445414d9943432 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:05:58 +0300 Subject: [PATCH 049/107] refactor(chat): unify throttled streaming buffers (#1338) (#1358) --- .../ui/chat/ChatStreamingController.kt | 197 ++++-------------- .../hermescontrol/ui/chat/ChatViewModel.kt | 8 +- .../hermescontrol/ui/chat/StreamingBuffer.kt | 58 ++++++ .../ui/chat/ChatStreamingControllerTest.kt | 30 ++- .../ui/chat/StreamingBufferTest.kt | 56 +++++ 5 files changed, 177 insertions(+), 172 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingBuffer.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/StreamingBufferTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt index 9652cf5f..2aa0191f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt @@ -2,26 +2,18 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.ws.WsEvent import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch /** * Holds chat message-streaming state and logic, extracted from [ChatViewModel] * to keep the god-object focused on messaging/session concerns. * - * Behavior is identical to the original inline implementation: it owns the - * streaming buffers and the two token/delta handlers, and mutates the shared - * [uiState] + [streamingState] flows. The owning ViewModel keeps [streamingState] - * as the single source of truth for the reduced [StreamingState] (applied via the - * WS event reducer); this controller only writes into it for buffer-driven flushes. - * - * [isCurrentSession] and [isTestEnvironment] are injected so the controller - * stays free of ViewModel-specific context while preserving exact behavior. - * [scope] mirrors the [com.m57.hermescontrol.ui.chat.ChatSearchDelegate] seam - * and is reserved for future streaming coroutine work. + * Owns the three throttled streams and their synchronous transition flushes, and mutates + * the shared [uiState] + [streamingState] flows. The owning ViewModel keeps + * [streamingState] as the single source of truth for the reduced [StreamingState]. + * [isCurrentSession] and [isTestEnvironment] preserve the existing session + * filtering and immediate-flush test behavior. */ class ChatStreamingController( private val scope: CoroutineScope, @@ -34,36 +26,22 @@ class ChatStreamingController( const val FLUSH_INTERVAL_MS = 33L } - private val streamingBuffer = java.lang.StringBuilder() - private var lastFlushMs = 0L - private var trailingMessageFlushJob: Job? = null - - private val thinkingBuffer = java.lang.StringBuilder() - private var lastThinkingFlushMs = 0L - private var trailingThinkingFlushJob: Job? = null - - private val reasoningBuffer = java.lang.StringBuilder() - private var lastReasoningFlushMs = 0L - private var trailingReasoningFlushJob: Job? = null - private fun nowMs(): Long = System.nanoTime() / 1_000_000L + private val tokens = StreamingBuffer(scope, FLUSH_INTERVAL_MS, ::nowMs, ::flushTokens) + private val thinking = StreamingBuffer(scope, FLUSH_INTERVAL_MS, ::nowMs, ::flushThinking) + private val reasoning = StreamingBuffer(scope, FLUSH_INTERVAL_MS, ::nowMs, ::flushReasoning) + /** * Resets all streaming buffers. Centralizes the clear logic that was * previously scattered across MessageStart, MessageComplete, MessageDone, * ToolStart, ClarifyRequest, session switches, and interrupt handling. */ fun resetStreaming() { - trailingMessageFlushJob?.cancel() - trailingThinkingFlushJob?.cancel() - trailingReasoningFlushJob?.cancel() - flushReasoning() - streamingBuffer.clear() - thinkingBuffer.clear() - reasoningBuffer.clear() - lastFlushMs = 0L - lastThinkingFlushMs = 0L - lastReasoningFlushMs = 0L + reasoning.flushPending() + tokens.clear() + thinking.clear() + reasoning.clear() // Issue #755: the shared streaming state must not carry the previous // message's reasoning into the next one. The finalized message keeps // its own copy (persisted to Room), so clearing here is safe. @@ -84,19 +62,13 @@ class ChatStreamingController( * Used at tool.start: the reducer keeps the streaming message + its * reasoning alive across the tool call (issue #771), and the VM must * not undo that by resetting the shared streaming state. The buffers - * themselves are drained (flushPendingReasoning) before reduce, so a + * themselves are drained before reduce, so a * buffer-only clear is safe and prevents stale deltas from re-flushing. */ fun clearStreamingBuffers() { - trailingMessageFlushJob?.cancel() - trailingThinkingFlushJob?.cancel() - trailingReasoningFlushJob?.cancel() - streamingBuffer.clear() - thinkingBuffer.clear() - reasoningBuffer.clear() - lastFlushMs = 0L - lastThinkingFlushMs = 0L - lastReasoningFlushMs = 0L + tokens.clear() + thinking.clear() + reasoning.clear() } /** Resets buffers and starts a fresh streaming message (called on MessageStart). */ @@ -104,78 +76,31 @@ class ChatStreamingController( resetStreaming() } - /** - * Flushes any throttled reasoning buffer before a state-transition event - * (MessageStart / MessageComplete / MessageDone / ToolStart). Without this, - * a reasoning delta that arrived just before a transition could be dropped - * because the next flush only fires on the ~33ms timer — so we force it out - * synchronously so the finalized message carries the latest reasoning. - */ - fun flushPendingReasoning() { - trailingReasoningFlushJob?.cancel() - flushReasoning() - } - - /** - * Force-flushes any throttled TOKEN buffer onto the streaming message - * before a state-transition event (ToolStart / MessageComplete / - * MessageDone). `handleMessageToken` only flushes when >=33ms have passed - * since the last flush, so a delta that lands just before the transition - * can still be sitting in the buffer. The reducer seals the streaming - * message into a finalized orphan at tool.start (issue #842) — sealing a - * stale copy truncated the narration's tail (observed on-device: the - * orphan ended "...chickpea" while the REST row ended "...chickpea - * goodness:"), which then failed the logical-match against the clean - * REST row and re-added the commentary as a ghost bubble. Flushing the - * tokens synchronously first makes the sealed orphan complete. - */ - fun flushPendingTokens() { - trailingMessageFlushJob?.cancel() - val currentContent = streamingBuffer.toString() - if (currentContent.isEmpty()) return - lastFlushMs = nowMs() - streamingState.update { state -> - val current = state.streamingMessage ?: return@update state - state.copy( - streamingMessage = - current.copy( - content = currentContent, - reasoningText = - if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText, - ), - ) + /** Drain throttled reasoning and tokens before the reducer seals or resets the message. */ + fun flushPendingTransition() { + reasoning.flushPending() + tokens.flushPending { content -> + streamingState.update { state -> + val current = state.streamingMessage ?: return@update state + state.copy( + streamingMessage = + current.copy( + content = content, + reasoningText = + if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText, + ), + ) + } } } fun handleMessageToken(event: WsEvent.MessageToken) { if (!isCurrentSession(event.sessionId)) return - streamingBuffer.append(event.token) - val now = nowMs() - - // Always flush in tests, or if enough time has passed - val shouldFlush = (now - lastFlushMs >= FLUSH_INTERVAL_MS) || lastFlushMs == 0L || isTestEnvironment() - if (shouldFlush) { - trailingMessageFlushJob?.cancel() - flushTokens(now) - } else { - scheduleTrailingMessageFlush() - } + tokens.append(event.token, isTestEnvironment()) } - private fun scheduleTrailingMessageFlush() { - if (trailingMessageFlushJob?.isActive == true) return - trailingMessageFlushJob = - scope.launch { - delay(FLUSH_INTERVAL_MS) - flushTokens(nowMs()) - } - } - - private fun flushTokens(now: Long) { - val currentContent = streamingBuffer.toString() - if (currentContent.isEmpty()) return - lastFlushMs = now + private fun flushTokens(currentContent: String) { streamingState.update { state -> val current = state.streamingMessage if (current != null) { @@ -210,33 +135,10 @@ class ChatStreamingController( fun handleThinkingDelta(event: WsEvent.ThinkingDelta) { if (!isCurrentSession(event.sessionId)) return - thinkingBuffer.append(event.token) - val now = nowMs() - - // Always flush in tests, or if enough time has passed - val shouldFlush = - (now - lastThinkingFlushMs >= FLUSH_INTERVAL_MS) || lastThinkingFlushMs == 0L || isTestEnvironment() - if (shouldFlush) { - trailingThinkingFlushJob?.cancel() - flushThinking(now) - } else { - scheduleTrailingThinkingFlush() - } + thinking.append(event.token, isTestEnvironment()) } - private fun scheduleTrailingThinkingFlush() { - if (trailingThinkingFlushJob?.isActive == true) return - trailingThinkingFlushJob = - scope.launch { - delay(FLUSH_INTERVAL_MS) - flushThinking(nowMs()) - } - } - - private fun flushThinking(now: Long) { - val currentContent = thinkingBuffer.toString() - if (currentContent.isEmpty()) return - lastThinkingFlushMs = now + private fun flushThinking(currentContent: String) { streamingState.update { state -> state.copy( isThinking = true, @@ -248,33 +150,10 @@ class ChatStreamingController( fun handleReasoningDelta(event: WsEvent.ReasoningDelta) { if (!isCurrentSession(event.sessionId)) return - reasoningBuffer.append(event.token) - val now = nowMs() - - // Always flush in tests, or if enough time has passed - val shouldFlush = - (now - lastReasoningFlushMs >= FLUSH_INTERVAL_MS) || lastReasoningFlushMs == 0L || isTestEnvironment() - if (shouldFlush) { - trailingReasoningFlushJob?.cancel() - flushReasoning(now) - } else { - scheduleTrailingReasoningFlush() - } + reasoning.append(event.token, isTestEnvironment()) } - private fun scheduleTrailingReasoningFlush() { - if (trailingReasoningFlushJob?.isActive == true) return - trailingReasoningFlushJob = - scope.launch { - delay(FLUSH_INTERVAL_MS) - flushReasoning(nowMs()) - } - } - - private fun flushReasoning(now: Long = nowMs()) { - val currentContent = reasoningBuffer.toString() - if (currentContent.isEmpty()) return - lastReasoningFlushMs = now + private fun flushReasoning(currentContent: String) { streamingState.update { state -> state.copy( isReasoning = 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 69b4a1c4..14763576 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 @@ -1042,13 +1042,7 @@ class ChatViewModel( is WsEvent.MessageDone, is WsEvent.ToolStart, -> { - streamingController.flushPendingReasoning() - // Issue #842: the token buffer can hold deltas that landed - // <33ms before the transition. The reducer seals the - // streaming message into the orphan at tool.start — flush - // first so the seal carries the COMPLETE narration (a - // truncated seal fails the later REST dedupe and ghosts). - streamingController.flushPendingTokens() + streamingController.flushPendingTransition() } else -> {} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingBuffer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingBuffer.kt new file mode 100644 index 00000000..445151e2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingBuffer.kt @@ -0,0 +1,58 @@ +package com.m57.hermescontrol.ui.chat + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** One throttled streaming channel: cumulative text, last flush and trailing timer. */ +internal class StreamingBuffer( + private val scope: CoroutineScope, + private val intervalMs: Long, + private val nowMs: () -> Long, + private val flush: (String) -> Unit, +) { + private val content = StringBuilder() + private var lastFlushMs = 0L + private var trailingJob: Job? = null + + fun append( + delta: String, + flushImmediately: Boolean, + ) { + content.append(delta) + val now = nowMs() + if (flushImmediately || lastFlushMs == 0L || now - lastFlushMs >= intervalMs) { + trailingJob?.cancel() + flushNow(now) + } else if (trailingJob?.isActive != true) { + trailingJob = + scope.launch { + delay(intervalMs) + flushNow(nowMs()) + } + } + } + + /** Keep the cumulative text: subsequent deltas append to the same message. */ + fun flushPending(override: ((String) -> Unit)? = null) { + trailingJob?.cancel() + flushNow(nowMs(), override) + } + + fun clear() { + trailingJob?.cancel() + trailingJob = null + content.clear() + lastFlushMs = 0L + } + + private fun flushNow( + now: Long, + override: ((String) -> Unit)? = null, + ) { + if (content.isEmpty()) return + lastFlushMs = now + (override ?: flush)(content.toString()) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt index 6efcd0a2..e6aaa5a9 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt @@ -9,11 +9,11 @@ import org.junit.Test class ChatStreamingControllerTest { @Test - fun flushPendingReasoning_flushesThrottledReasoningOnTransition() = + fun flushPendingTransition_flushesThrottledReasoning() = runTest { // isTestEnvironment = false so the ~33ms throttle is live. The FIRST delta // always flushes (lastFlushMs == 0), but a SECOND delta within 33ms is held - // in the buffer and only pushed out by flushPendingReasoning() (called at + // in the buffer and only pushed out by flushPendingTransition() (called at // streaming transitions) — that is the exact drop we guard against. val uiState = MutableStateFlow(ChatUiState()) val streamingState = @@ -38,13 +38,13 @@ class ChatStreamingControllerTest { assertEquals("A", streamingState.value.streamingMessage?.reasoningText) // A streaming transition forces the buffered reasoning onto the message. - controller.flushPendingReasoning() + controller.flushPendingTransition() assertEquals("AB", streamingState.value.streamingMessage?.reasoningText) } @Test - fun flushPendingTokens_flushesThrottledTokensBeforeSeal() = + fun flushPendingTransition_flushesThrottledTokensBeforeSeal() = runTest { // Issue #842: a delta landing <33ms before tool.start stays in the // throttled buffer; flushing the tokens before the reducer seals the @@ -73,7 +73,7 @@ class ChatStreamingControllerTest { // The tool.start transition forces the buffered tokens out so the // sealed orphan carries the complete narration. - controller.flushPendingTokens() + controller.flushPendingTransition() assertEquals( "tool's loaded! 🔍 now searchin' for the best hummus", @@ -95,7 +95,7 @@ class ChatStreamingControllerTest { // A fresh reasoning delta + flush must carry ONLY the new value, proving the // stale buffer was cleared and cannot be resurrected. controller.handleReasoningDelta(WsEvent.ReasoningDelta("Fresh reasoning", "session")) - controller.flushPendingReasoning() + controller.flushPendingTransition() assertEquals("Fresh reasoning", streamingState.value.reasoningText) } @@ -168,6 +168,24 @@ class ChatStreamingControllerTest { assertEquals("First Second", streamingState.value.streamingMessage?.content) } + @Test + fun thinkingDelta_trailingFlushAndResetDoNotLeakIntoNextTurn() = + runTest { + val uiState = MutableStateFlow(ChatUiState()) + val streamingState = MutableStateFlow(StreamingState()) + val controller = controller(this, uiState, streamingState, isTestEnvironment = { false }) + + controller.handleThinkingDelta(WsEvent.ThinkingDelta("Think", "session")) + controller.handleThinkingDelta(WsEvent.ThinkingDelta(" more", "session")) + assertEquals("Think", streamingState.value.thinkingText) + testScheduler.advanceTimeBy(35L) + assertEquals("Think more", streamingState.value.thinkingText) + + controller.resetStreaming() + controller.handleThinkingDelta(WsEvent.ThinkingDelta("Fresh", "session")) + assertEquals("Fresh", streamingState.value.thinkingText) + } + private fun controller( scope: CoroutineScope, uiState: MutableStateFlow, diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/StreamingBufferTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/StreamingBufferTest.kt new file mode 100644 index 00000000..8d501cac --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/StreamingBufferTest.kt @@ -0,0 +1,56 @@ +package com.m57.hermescontrol.ui.chat + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class StreamingBufferTest { + @Test + fun channelsThrottleIndependentlyAndFlushCumulativeText() = + runTest { + var now = 100L + val tokens = mutableListOf() + val reasoning = mutableListOf() + val tokenBuffer = StreamingBuffer(this, 33L, { now }, tokens::add) + val reasoningBuffer = StreamingBuffer(this, 33L, { now }, reasoning::add) + + tokenBuffer.append("A", flushImmediately = false) + tokenBuffer.append("B", flushImmediately = false) + reasoningBuffer.append("R", flushImmediately = false) + assertEquals(listOf("A"), tokens) + assertEquals(listOf("R"), reasoning) + + now += 34 + testScheduler.advanceTimeBy(34L) + assertEquals(listOf("A", "AB"), tokens) + assertEquals(listOf("R"), reasoning) + } + + @Test + fun transitionFlushCancelsTrailingJobWithoutDroppingTheNextDelta() = + runTest { + val output = mutableListOf() + val buffer = StreamingBuffer(this, 33L, { 100L }, output::add) + buffer.append("A", flushImmediately = false) + buffer.append("B", flushImmediately = false) + buffer.flushPending() + testScheduler.advanceTimeBy(34L) + assertEquals(listOf("A", "AB"), output) + + buffer.append("C", flushImmediately = true) + assertEquals(listOf("A", "AB", "ABC"), output) + } + + @Test + fun clearCancelsTrailingJobAndStartsFresh() = + runTest { + val output = mutableListOf() + val buffer = StreamingBuffer(this, 33L, { 100L }, output::add) + buffer.append("Old", flushImmediately = false) + buffer.append(" tail", flushImmediately = false) + buffer.clear() + testScheduler.advanceTimeBy(34L) + buffer.append("Fresh", flushImmediately = false) + assertEquals(listOf("Old", "Fresh"), output) + } +} From cc98f3ba89038fa04affbe7d20815ce1e5e386ad Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:59:29 +0300 Subject: [PATCH 050/107] fix(chat): serialize scroll follow and timeline jumps (#1322, #1323) (#1359) --- .../chat/fullbleed/FullBleedChatListTest.kt | 36 +++++++-- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 3 +- .../chat/components/ChatScrollController.kt | 79 +++++++------------ .../ui/chat/fullbleed/FullBleedChatList.kt | 3 + .../components/ChatScrollControllerTest.kt | 79 +++++++++++++++++++ 5 files changed, 142 insertions(+), 58 deletions(-) diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt index a40a1c10..cf323c1e 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt @@ -3,6 +3,8 @@ package com.m57.hermescontrol.ui.chat.fullbleed import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertCountEquals @@ -12,6 +14,9 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest @@ -25,6 +30,7 @@ import com.m57.hermescontrol.ui.chat.ToolStatus import com.m57.hermescontrol.ui.chat.components.ChatScrollController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import org.junit.Assert.assertFalse import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -72,9 +78,18 @@ class FullBleedChatListTest { showUserMessageTokens: Boolean = true, showAssistantMessageTokens: Boolean = true, showTokensPerSecond: Boolean = true, + onController: (ChatScrollController) -> Unit = {}, ) { composeTestRule.setContent { - val listState = LazyListState() + val listState = remember { LazyListState() } + val scrollController = + remember(listState) { + ChatScrollController( + listState = listState, + scope = CoroutineScope(Dispatchers.Main.immediate), + ) + } + SideEffect { onController(scrollController) } FullBleedChatList( transcript = TranscriptUiState.resolve( @@ -106,15 +121,24 @@ class FullBleedChatListTest { actions = testTranscriptActions(), searchState = ChatSearchState(), listState = listState, - scrollController = - ChatScrollController( - listState = listState, - scope = CoroutineScope(Dispatchers.Main.immediate), - ), + scrollController = scrollController, ) } } + @Test + fun upwardUserGesture_pausesBottomFollow() { + lateinit var scrollController: ChatScrollController + render( + messages = List(40) { index -> msg("u$index", MessageRole.USER) }, + onController = { scrollController = it }, + ) + composeTestRule.runOnIdle { scrollController.jumpToBottom() } + composeTestRule.waitForIdle() + composeTestRule.onRoot().performTouchInput { swipeDown() } + composeTestRule.runOnIdle { assertFalse(scrollController.isFollowingBottom) } + } + @Test fun userMessages_keepBubble_agentProse_isFullBleed() { render( 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 6f5baa49..dcd8fbcf 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 @@ -329,8 +329,7 @@ fun ChatScreen( } LaunchedEffect(timelineState.historyAnchorRowId) { if (timelineState.isHistorical && displayedMessages.isNotEmpty()) { - scrollController.pauseFollowing() - listState.scrollToItem(0) + scrollController.jumpToHistoryStart() } } val showScrollToBottom by remember(timelineState.isHistorical, displayedMessages) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt index 60a6c506..1875ad62 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt @@ -10,10 +10,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield /** @@ -60,14 +59,8 @@ class ChatScrollController( private var lastTailKey: Any? = null private var lastMessageCount: Int = 0 - /** - * Guard flag: true while a programmatic scroll-to-bottom is in flight. - * Prevents [observeUserScrollPosition] from flipping [isFollowingBottom] - * to false during the layout race — the scroll targets the old last item, - * Compose lays out the new item below, and [isAtBottom] momentarily - * returns false before the second scroll pass catches it. - */ - private var isProgrammaticScroll by mutableStateOf(false) + /** The sole active scroll command. Replaced by newer tail, search or explicit jumps. */ + private var scrollJob: Job? = null /** * Pixel tolerance for "at bottom" detection. Covers the LazyColumn's @@ -77,35 +70,31 @@ class ChatScrollController( */ var bottomPixelTolerance by mutableStateOf(48) - /** Serialized scroll scope — all scroll jobs funnel through here. */ - fun launchScroll(block: suspend CoroutineScope.() -> Unit) { - scope.launch(block = block) + /** Replace the previous scroll command; user gestures can cancel the same job. */ + private fun launchScroll(block: suspend CoroutineScope.() -> Unit) { + scrollJob?.cancel() + scrollJob = scope.launch(block = block) } - /** - * Read the live bottom-follow state from [LazyListState] and react to - * user-driven scroll position changes. Should be called inside a - * `LaunchedEffect(Unit)` so it observes the list for the screen's lifetime. - */ + /** Observe only arrival at the bottom; departures are owned by actual user input. */ fun observeUserScrollPosition() { scope.launch { + var previouslyAtBottom = listState.isAtBottom(bottomPixelTolerance) snapshotFlow { listState.isAtBottom(bottomPixelTolerance) } .distinctUntilChanged() .collect { atBottom -> - if (atBottom) { + if (atBottom && !previouslyAtBottom) { isFollowingBottom = true - // Reaching the bottom means the reader caught up. pendingCount = 0 - } else if (!isProgrammaticScroll) { - // Only break follow on USER-initiated scrolls. - // During programmatic scrolls the viewport is - // transiently not-at-bottom while new items lay out. - isFollowingBottom = false } + previouslyAtBottom = atBottom } } } + /** An upward user gesture takes ownership from auto-follow immediately. */ + fun onUserScrollUp() = pauseFollowing() + /** * Call on every tail-content change (new message, streaming token, thinking * toggle, subagent card, clarify prompt). [tailKey] must be a stable value @@ -132,14 +121,7 @@ class ChatScrollController( // Pin instantly so rapid streamed tokens don't queue competing // animations; the reader stays glued to the growing tail. // Use layout-aware scrolling to survive the Compose layout race. - scope.launch { - isProgrammaticScroll = true - try { - scrollToBottomAwaitingLayout() - } finally { - isProgrammaticScroll = false - } - } + launchScroll { scrollToBottomAwaitingLayout() } } else { pendingCount += newMessages } @@ -163,14 +145,10 @@ class ChatScrollController( yield() yield() listState.scrollToBottom(animated = animated) - // If we're still not at the bottom after the first scroll (e.g. a - // new item was laid out between the scroll and now), do one more pass. + // Yield once more if layout changed during the first pass, then align + // the current last row; newer tails replace this job instead of racing it. if (!listState.isAtBottom(bottomPixelTolerance)) { - // Wait up to 150ms for the layout to settle with the new items. - withTimeoutOrNull(150L) { - snapshotFlow { listState.layoutInfo.totalItemsCount } - .first { it > 0 } - } + yield() listState.scrollToBottom(animated = animated) } } @@ -179,19 +157,19 @@ class ChatScrollController( fun jumpToBottom(animated: Boolean = false) { pendingCount = 0 isFollowingBottom = true - scope.launch { - isProgrammaticScroll = true - try { - scrollToBottomAwaitingLayout(animated = animated) - } finally { - isProgrammaticScroll = false - } - } + launchScroll { scrollToBottomAwaitingLayout(animated = animated) } } /** An explicit history gesture must not be undone by a short list's bottom-follow. */ fun pauseFollowing() { isFollowingBottom = false + scrollJob?.cancel() + } + + /** Historical timeline navigation shares the scroll command with follow and search. */ + fun jumpToHistoryStart() { + pauseFollowing() + launchScroll { listState.scrollToItem(0) } } /** FAB tap: resume following + clear unread. */ @@ -218,10 +196,11 @@ class ChatScrollController( contentOffset: Int, contentLength: Int, ) { - scope.launch { + pauseFollowing() + launchScroll { listState.animateScrollToItem(index) val info = listState.layoutInfo - val item = info.visibleItemsInfo.firstOrNull { it.index == index } ?: return@launch + val item = info.visibleItemsInfo.firstOrNull { it.index == index } ?: return@launchScroll val viewportHeight = info.viewportEndOffset - info.viewportStartOffset val fraction = if (contentLength > 0) contentOffset.toFloat() / contentLength else 0f val targetTop = (viewportHeight / 3) - (item.size * fraction).toInt() 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 998facc2..c49c0eae 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 @@ -237,6 +237,9 @@ fun FullBleedChatList( available: Offset, source: NestedScrollSource, ): Offset { + if (source == NestedScrollSource.UserInput && available.y > 0f) { + scrollController.onUserScrollUp() + } if (prefetch.onScroll( firstVisibleIndex = listState.firstVisibleItemIndex, deltaY = available.y, diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatScrollControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatScrollControllerTest.kt index 60e888d5..0f675376 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatScrollControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatScrollControllerTest.kt @@ -3,16 +3,22 @@ package com.m57.hermescontrol.ui.chat.components import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After @@ -151,6 +157,79 @@ class ChatScrollControllerTest { coVerify(exactly = 0) { listState.animateScrollToItem(any(), any()) } } + @Test + fun `layout growth does not pause follow but returning to bottom after user gesture resumes`() = + runTest(testDispatcher) { + var atBottom by mutableStateOf(true) + val listState = mockLazyListStateAtBottom() + val layoutInfo = listState.layoutInfo + val itemInfo = layoutInfo.visibleItemsInfo.single() + every { itemInfo.index } answers { if (atBottom) 4 else 3 } + val controller = ChatScrollController(listState, backgroundScope) + + controller.observeUserScrollPosition() + runCurrent() + atBottom = false // A new row laid out below the viewport, not a user scroll. + Snapshot.sendApplyNotifications() + runCurrent() + assertTrue(controller.isFollowingBottom) + + controller.onUserScrollUp() + assertFalse(controller.isFollowingBottom) + atBottom = true + Snapshot.sendApplyNotifications() + runCurrent() + assertTrue(controller.isFollowingBottom) + } + + @Test + fun `user upward gesture cancels in-flight follow and counts new messages`() = + runTest(testDispatcher) { + val listState = mockLazyListStateAtBottom() + coEvery { listState.scrollToItem(4, any()) } coAnswers { delay(1_000) } + val controller = ChatScrollController(listState, this) + + controller.onTailChanged(tailKey = "first", messageCount = 1) + runCurrent() + controller.onUserScrollUp() + controller.onTailChanged(tailKey = "second", messageCount = 2) + advanceUntilIdle() + + assertFalse(controller.isFollowingBottom) + assertEquals(1, controller.pendingCount) + coVerify(exactly = 1) { listState.scrollToItem(4, any()) } + } + + @Test + fun `historical jump replaces an in-flight follow and leaves follow paused`() = + runTest(testDispatcher) { + val listState = mockLazyListStateAtBottom() + coEvery { listState.scrollToItem(4, any()) } coAnswers { delay(1_000) } + val controller = ChatScrollController(listState, this) + + controller.onTailChanged(tailKey = "tail", messageCount = 1) + runCurrent() + controller.jumpToHistoryStart() + advanceUntilIdle() + + assertFalse(controller.isFollowingBottom) + coVerify(exactly = 1) { listState.scrollToItem(0, any()) } + } + + @Test + fun `rapid tails replace older jobs rather than queueing scrolls`() = + runTest(testDispatcher) { + val listState = mockLazyListStateAtBottom() + val controller = ChatScrollController(listState, this) + + controller.onTailChanged(tailKey = "one", messageCount = 1) + controller.onTailChanged(tailKey = "two", messageCount = 2) + advanceUntilIdle() + + assertTrue(controller.isFollowingBottom) + coVerify(exactly = 1) { listState.scrollToItem(4, any()) } + } + @Test fun `showFab returns true only when paused and content is present`() { val listState = mockk(relaxed = true) From 580f3b9697751d56063abc7198ec0f72087159bd Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:04:05 +0300 Subject: [PATCH 051/107] fix(#1331, #1332): render nested markdown blocks and content-sized tables (#1360) --- .../ui/chat/MarkdownHeadingRenderingTest.kt | 31 +++ .../ui/chat/MarkdownTableRenderingTest.kt | 30 +++ .../m57/hermescontrol/ui/chat/MarkdownText.kt | 180 +++++++++++------- .../ui/chat/markdown/MarkdownModels.kt | 3 + .../ui/chat/markdown/MarkdownParser.kt | 52 +++-- .../ui/chat/markdown/MarkdownTableView.kt | 32 +++- .../ui/chat/MarkdownTextFeatureTest.kt | 22 +++ 7 files changed, 257 insertions(+), 93 deletions(-) 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 index ec36d0dc..9758bc49 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt @@ -1,8 +1,10 @@ package com.m57.hermescontrol.ui.chat import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest @@ -30,4 +32,33 @@ class MarkdownHeadingRenderingTest { composeTestRule.onNodeWithText("Set foo now").assertIsDisplayed() composeTestRule.onNodeWithText("**Set `foo` now**").assertDoesNotExist() } + + @Test + fun quoteAndListContinuationRenderNestedCodeBlocks() { + composeTestRule.setContent { + MaterialTheme { + MarkdownText( + text = + """ + > # Quote heading + > ```kotlin + > val quoted = 1 + > ``` + + - Item + ```kotlin + val listed = 2 + ``` + """.trimIndent(), + textColor = MaterialTheme.colorScheme.onSurface, + ) + } + } + + composeTestRule.onNodeWithText("Quote heading").assertIsDisplayed() + composeTestRule.onNodeWithText("Item").assertIsDisplayed() + composeTestRule.onAllNodesWithTag("code_block").assertCountEquals(2) + composeTestRule.onNodeWithText("val quoted = 1", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("val listed = 2", substring = true).assertIsDisplayed() + } } 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 index 559a5fc8..4272cbb5 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt @@ -1,11 +1,19 @@ package com.m57.hermescontrol.ui.chat +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -32,4 +40,26 @@ class MarkdownTableRenderingTest { composeTestRule.onNodeWithText("**Name**").assertDoesNotExist() composeTestRule.onNodeWithText("`code`").assertDoesNotExist() } + + @Test + fun columnsSizeToContentAndStayAlignedAcrossRows() { + composeTestRule.setContent { + MaterialTheme { + Box(Modifier.width(220.dp)) { + MarkdownText( + text = "| ID | Description |\n| --- | --- |\n| A | A considerably longer table value |", + textColor = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + + val idHeader = composeTestRule.onNodeWithTag("markdown_table_header_0").getUnclippedBoundsInRoot() + val descHeader = composeTestRule.onNodeWithTag("markdown_table_header_1").getUnclippedBoundsInRoot() + val idCell = composeTestRule.onNodeWithTag("markdown_table_cell_0_0").getUnclippedBoundsInRoot() + val descCell = composeTestRule.onNodeWithTag("markdown_table_cell_0_1").getUnclippedBoundsInRoot() + assertTrue(descHeader.right - descHeader.left > idHeader.right - idHeader.left) + assertEquals(idHeader.right - idHeader.left, idCell.right - idCell.left) + assertEquals(descHeader.right - descHeader.left, descCell.right - descCell.left) + } } 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 e52b742f..03bf817d 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 @@ -152,14 +152,14 @@ fun MarkdownText( } is MdBlock.Heading -> { - val fontSize = + val headingStyle = when (block.level) { - 1 -> 22.sp - 2 -> 20.sp - 3 -> 18.sp - 4 -> 16.sp - 5 -> 15.sp - else -> 14.sp + 1 -> MaterialTheme.typography.headlineMedium + 2 -> MaterialTheme.typography.headlineSmall + 3 -> MaterialTheme.typography.titleMedium + 4 -> MaterialTheme.typography.titleSmall + 5 -> MaterialTheme.typography.labelLarge + else -> MaterialTheme.typography.labelMedium } val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) } val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current @@ -169,12 +169,10 @@ fun MarkdownText( textColor = textColor, latexMeasurer = latexMeasurer, style = - MaterialTheme.typography.bodyMedium - .copy( - fontSize = fontSize, - fontWeight = FontWeight.Bold, - textDirection = bidiTextDirection(isRtl), - ), + headingStyle.copy( + fontWeight = FontWeight.Bold, + textDirection = bidiTextDirection(isRtl), + ), searchQuery = searchQuery, isCurrentMatch = isCurrentMatch, linkColor = linkColor, @@ -209,20 +207,30 @@ fun MarkdownText( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 6.dp), ) - MarkdownInlineText( - text = block.text, - textColor = textColor, - latexMeasurer = latexMeasurer, - style = - MaterialTheme.typography.bodyMedium.copy( - textDirection = bidiTextDirection(isRtl), - ), - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - modifier = Modifier.weight(1f), - ) + Column(modifier = Modifier.weight(1f)) { + MarkdownInlineText( + text = block.text, + textColor = textColor, + latexMeasurer = latexMeasurer, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = bidiTextDirection(isRtl), + ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + ) + if (block.nestedSource.isNotEmpty()) { + MarkdownText( + text = block.nestedSource, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + onImageClick = onImageClick, + ) + } + } } } } @@ -258,20 +266,30 @@ fun MarkdownText( }, modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp), ) - MarkdownInlineText( - text = block.text, - textColor = textColor, - latexMeasurer = latexMeasurer, - style = - MaterialTheme.typography.bodyMedium.copy( - textDirection = bidiTextDirection(isRtl), - ), - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - modifier = Modifier.weight(1f), - ) + Column(modifier = Modifier.weight(1f)) { + MarkdownInlineText( + text = block.text, + textColor = textColor, + latexMeasurer = latexMeasurer, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = bidiTextDirection(isRtl), + ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + ) + if (block.nestedSource.isNotEmpty()) { + MarkdownText( + text = block.nestedSource, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + onImageClick = onImageClick, + ) + } + } } } } @@ -295,20 +313,30 @@ fun MarkdownText( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 6.dp), ) - MarkdownInlineText( - text = block.text, - textColor = textColor, - latexMeasurer = latexMeasurer, - style = - MaterialTheme.typography.bodyMedium.copy( - textDirection = bidiTextDirection(isRtl), - ), - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - modifier = Modifier.weight(1f), - ) + Column(modifier = Modifier.weight(1f)) { + MarkdownInlineText( + text = block.text, + textColor = textColor, + latexMeasurer = latexMeasurer, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = bidiTextDirection(isRtl), + ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + ) + if (block.nestedSource.isNotEmpty()) { + MarkdownText( + text = block.nestedSource, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + onImageClick = onImageClick, + ) + } + } } } } @@ -330,21 +358,33 @@ fun MarkdownText( .background(textColor.copy(alpha = 0.35f)), ) Spacer(modifier = Modifier.width(8.dp)) - MarkdownInlineText( - text = block.text, - textColor = textColor, - latexMeasurer = latexMeasurer, - style = - MaterialTheme.typography.bodyMedium.copy( - fontStyle = FontStyle.Italic, - textDirection = bidiTextDirection(isRtl), - ), - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - modifier = Modifier.weight(1f), - ) + val nestedBlocks = remember(block.text) { parseBlocks(block.text) } + if (nestedBlocks.size == 1 && nestedBlocks.single() is MdBlock.Paragraph) { + MarkdownInlineText( + text = block.text, + textColor = textColor, + latexMeasurer = latexMeasurer, + style = + MaterialTheme.typography.bodyMedium.copy( + fontStyle = FontStyle.Italic, + textDirection = bidiTextDirection(isRtl), + ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + modifier = Modifier.weight(1f), + ) + } else { + MarkdownText( + text = block.text, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + modifier = Modifier.weight(1f), + onImageClick = onImageClick, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt index 87bfed26..e357a206 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt @@ -18,18 +18,21 @@ sealed interface MdBlock { data class Bullet( val text: String, val level: Int = 0, + val nestedSource: String = "", ) : MdBlock data class Task( val checked: Boolean, val text: String, val level: Int = 0, + val nestedSource: String = "", ) : MdBlock data class Ordered( val index: Int, val text: String, val level: Int = 0, + val nestedSource: String = "", ) : MdBlock data class Image( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt index d7879285..5044775d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt @@ -164,7 +164,7 @@ fun parseBlocks(src: String): List { line.startsWith(">") -> { val quote = mutableListOf() while (i < lines.size && lines[i].startsWith(">")) { - quote.add(lines[i].removePrefix(">").trim()) + quote.add(lines[i].removePrefix(">").removePrefix(" ")) i++ } blocks.add(MdBlock.Quote(quote.joinToString("\n"))) @@ -287,6 +287,11 @@ private fun parseList( } if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) { j = lookahead + } else if (items.isNotEmpty() && lookahead < lines.size && + computeIndent(lines[lookahead].takeWhile { it == ' ' || it == '\t' }) >= 2 + ) { + items.last().continuationLines.add("") + j = lookahead } else { break } @@ -334,32 +339,34 @@ private fun parseList( level = indentStack.size - 1 } - val fullText = - if (item.continuationLines.isEmpty()) { - when (item) { - is ParsedItem.Bullet -> item.text - is ParsedItem.Task -> item.text - is ParsedItem.Ordered -> item.text - } + val base = + when (item) { + is ParsedItem.Bullet -> item.text + is ParsedItem.Task -> item.text + is ParsedItem.Ordered -> item.text + } + val nestedSource = + if (item.continuationLines.any(::startsNestedBlock)) { + item.continuationLines.joinToString("\n") } else { - val base = - when (item) { - is ParsedItem.Bullet -> item.text - is ParsedItem.Task -> item.text - is ParsedItem.Ordered -> item.text - } - base + " " + item.continuationLines.joinToString(" ") + "" + } + val fullText = + if (nestedSource.isNotEmpty()) { + base + } else { + (listOf(base) + item.continuationLines).joinToString(" ").trim() } when (item) { is ParsedItem.Bullet -> { - blocks.add(MdBlock.Bullet(fullText, level = level)) + blocks.add(MdBlock.Bullet(fullText, level = level, nestedSource = nestedSource)) lastItemWasOrdered = false lastLevel = level } is ParsedItem.Task -> { - blocks.add(MdBlock.Task(item.checked, fullText, level = level)) + blocks.add(MdBlock.Task(item.checked, fullText, level = level, nestedSource = nestedSource)) lastItemWasOrdered = false lastLevel = level } @@ -377,7 +384,7 @@ private fun parseList( currentSequenceNumber = item.rawNumber currentSequenceNumber } - blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level)) + blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level, nestedSource = nestedSource)) lastItemWasOrdered = true lastLevel = level } @@ -387,6 +394,15 @@ private fun parseList( return blocks to j } +/** Preserve block syntax in indented list continuations instead of flattening it into prose. */ +private fun startsNestedBlock(line: String): Boolean = + parseCodeFenceStart(line) != null || + isValidHeading(line) || + line.startsWith(">") || + isHorizontalRule(line) || + line.startsWith("$$") || + line.startsWith("\\[") + private fun isHorizontalRule(line: String): Boolean { val trimmed = line.trim() if (trimmed.length < 3) return false 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 2d2cc26c..5951d53f 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 @@ -16,8 +16,11 @@ 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.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp @@ -25,8 +28,6 @@ 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 - @Composable fun MarkdownTable( block: MdBlock.Table, @@ -46,6 +47,25 @@ fun MarkdownTable( val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current val headerBg = textColor.copy(alpha = 0.08f) val alignments = block.alignments + val cellStyle = MaterialTheme.typography.bodySmall + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + val columnWidths = + remember(block, cellStyle, density, textMeasurer) { + val padding = with(density) { 12.dp.roundToPx() } + val minimum = with(density) { 48.dp.roundToPx() } + val maximum = with(density) { 280.dp.roundToPx() } + val columnCount = maxOf(block.header.size, block.rows.maxOfOrNull { it.size } ?: 0) + List(columnCount) { column -> + val cells = + listOfNotNull(block.header.getOrNull(column)) + block.rows.mapNotNull { it.getOrNull(column) } + val contentWidth = + cells.maxOfOrNull { cell -> + textMeasurer.measure(text = cell.take(128), style = cellStyle, maxLines = 1).size.width + } ?: 0 + with(density) { (contentWidth + padding).coerceIn(minimum, maximum).toDp() } + } + } CompositionLocalProvider(LocalLayoutDirection provides tableDirection) { Column( modifier = @@ -72,14 +92,15 @@ fun MarkdownTable( highlights = highlights, modifier = Modifier - .width(TABLE_COL_WIDTH) + .width(columnWidths[idx]) + .testTag("markdown_table_header_$idx") .padding(6.dp), ) } } HorizontalDivider(color = textColor.copy(alpha = 0.25f)) // Body rows - block.rows.forEach { row -> + block.rows.forEachIndexed { rowIndex, row -> Row(verticalAlignment = Alignment.Top) { row.forEachIndexed { idx, cell -> MarkdownInlineText( @@ -96,7 +117,8 @@ fun MarkdownTable( highlights = highlights, modifier = Modifier - .width(TABLE_COL_WIDTH) + .width(columnWidths[idx]) + .testTag("markdown_table_cell_${rowIndex}_$idx") .padding(6.dp), ) } 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 91bed590..36e8dbfa 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 @@ -459,6 +459,28 @@ class MarkdownTextFeatureTest { assertEquals(0, o16.level) } + @Test + fun quotedBlocksRetainNestedHeadingsListsAndFences() { + val source = "> # Heading\n> - item\n> ```kotlin\n> val x = 1\n> ```" + val quote = parseBlocks(source).single() as MdBlock.Quote + val nested = parseBlocks(quote.text) + assertTrue(nested.any { it is MdBlock.Heading && it.text == "Heading" }) + val bullet = nested.filterIsInstance().single() + assertEquals("item", bullet.text) + assertEquals("val x = 1", (parseBlocks(bullet.nestedSource).single() as MdBlock.Code).code) + } + + @Test + fun listItemsRetainFencedCodeAndHeadingsAsNestedBlocks() { + val source = "- intro\n ```kotlin\n val x = 1\n ```\n # Inside\n- next" + val bullets = parseBlocks(source).filterIsInstance() + assertEquals(listOf("intro", "next"), bullets.map { it.text }) + val nested = parseBlocks(bullets.first().nestedSource) + assertTrue(nested.any { it is MdBlock.Code && it.code == "val x = 1" }) + assertTrue(nested.any { it is MdBlock.Heading && it.text == "Inside" }) + assertEquals("", bullets.last().nestedSource) + } + // 19. NESTED CODEBLOCKS & EXTENDED CODE FENCES @Test fun testNestedCodeBlock_fourBackticksWrappingThreeBackticks() { From 85903afd388554596e782c5c8b0ad9db7ea1498b Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:31:43 +0300 Subject: [PATCH 052/107] fix(chat): searchable tool/reasoning rows and live markdown (#1361) --- .../ui/chat/ChatSearchRenderingTest.kt | 49 ++++++++++ .../ui/chat/MarkdownHeadingRenderingTest.kt | 17 ++++ .../chat/fullbleed/FullBleedChatListTest.kt | 41 ++++++-- .../ui/chat/ChatSearchController.kt | 97 +++++++++++++------ .../ui/chat/ChatSearchDelegate.kt | 2 + .../ui/chat/ChatSearchHighlight.kt | 39 ++++++++ .../hermescontrol/ui/chat/ChatSearchState.kt | 4 + .../m57/hermescontrol/ui/chat/MarkdownText.kt | 25 +---- .../m57/hermescontrol/ui/chat/ToolBubble.kt | 24 ++++- .../hermescontrol/ui/chat/ToolBubbleHeader.kt | 10 +- .../m57/hermescontrol/ui/chat/UserBubble.kt | 43 -------- .../ui/chat/components/ReasoningCard.kt | 20 +++- .../chat/fullbleed/ChatSearchScrollEffect.kt | 42 ++++++++ .../ui/chat/fullbleed/FullBleedChatList.kt | 89 +++++++++++------ .../ui/chat/fullbleed/FullBleedToolRow.kt | 4 +- .../ui/chat/fullbleed/FullBleedTurns.kt | 19 +++- .../ui/chat/ChatSearchControllerTest.kt | 26 +++-- .../ui/chat/ChatSearchDelegateTest.kt | 33 +++++++ .../ui/chat/FullBleedItemKeysTest.kt | 16 +++ 19 files changed, 454 insertions(+), 146 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatSearchRenderingTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchHighlight.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/ChatSearchScrollEffect.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatSearchRenderingTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatSearchRenderingTest.kt new file mode 100644 index 00000000..bb7b1d1f --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatSearchRenderingTest.kt @@ -0,0 +1,49 @@ +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 com.m57.hermescontrol.ui.chat.components.ReasoningCard +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ChatSearchRenderingTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun reasoningMatchExpandsCollapsedCardAndShowsTheHit() { + composeTestRule.setContent { + MaterialTheme { + ReasoningCard( + reasoningText = "a hidden needle in the trace", + searchQuery = "needle", + isCurrentMatch = true, + ) + } + } + + composeTestRule.onNodeWithText("a hidden needle in the trace").assertIsDisplayed() + } + + @Test + fun toolMatchRemainsVisibleInCollapsedHeader() { + composeTestRule.setContent { + MaterialTheme { + ToolBubble( + message = ChatMessage(role = MessageRole.TOOL, content = "opaque", toolName = "terminal"), + searchQuery = "terminal", + isCurrentMatch = true, + ) + } + } + + composeTestRule.onNodeWithText("terminal").assertIsDisplayed() + } +} 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 index 9758bc49..cdad49b8 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt @@ -61,4 +61,21 @@ class MarkdownHeadingRenderingTest { composeTestRule.onNodeWithText("val quoted = 1", substring = true).assertIsDisplayed() composeTestRule.onNodeWithText("val listed = 2", substring = true).assertIsDisplayed() } + + @Test + fun streamingMarkdownRendersBlocksBeforeCompletion() { + composeTestRule.setContent { + MaterialTheme { + MarkdownText( + text = "## Live heading\n\n**ready**\n\n```kotlin\nval x = 1\n```", + textColor = MaterialTheme.colorScheme.onSurface, + isStreaming = true, + ) + } + } + + composeTestRule.onNodeWithText("Live heading").assertIsDisplayed() + composeTestRule.onNodeWithText("ready").assertIsDisplayed() + composeTestRule.onAllNodesWithTag("code_block").assertCountEquals(1) + } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt index cf323c1e..e036eed9 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertCountEquals @@ -25,11 +26,11 @@ import com.m57.hermescontrol.ui.chat.ChatSearchState import com.m57.hermescontrol.ui.chat.ChatTimelineState import com.m57.hermescontrol.ui.chat.ChatUiState import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.SearchTarget import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.ToolStatus import com.m57.hermescontrol.ui.chat.components.ChatScrollController -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController import org.junit.Assert.assertFalse import org.junit.Rule import org.junit.Test @@ -78,17 +79,13 @@ class FullBleedChatListTest { showUserMessageTokens: Boolean = true, showAssistantMessageTokens: Boolean = true, showTokensPerSecond: Boolean = true, + searchState: ChatSearchState = ChatSearchState(), onController: (ChatScrollController) -> Unit = {}, ) { composeTestRule.setContent { val listState = remember { LazyListState() } - val scrollController = - remember(listState) { - ChatScrollController( - listState = listState, - scope = CoroutineScope(Dispatchers.Main.immediate), - ) - } + // Frame-clocked scope, matching ChatScreen: animated scrolls need a MonotonicFrameClock. + val scrollController = rememberChatScrollController(listState, rememberCoroutineScope()) SideEffect { onController(scrollController) } FullBleedChatList( transcript = @@ -119,7 +116,7 @@ class FullBleedChatListTest { speakingMessageId = null, ), actions = testTranscriptActions(), - searchState = ChatSearchState(), + searchState = searchState, listState = listState, scrollController = scrollController, ) @@ -139,6 +136,30 @@ class FullBleedChatListTest { composeTestRule.runOnIdle { assertFalse(scrollController.isFollowingBottom) } } + @Test + fun searchNavigatesToToolRowBeyondInitialViewport() { + val search = + ChatSearchState().apply { + isActive = true + query = "terminal" + matchIndices = listOf(30) + matchOffsets = listOf(0) + matchTargets = listOf(SearchTarget.TOOL) + currentIndex = 0 + matchedIds = setOf("tool") + currentMatchId = "tool" + } + render( + messages = + List(30) { index -> msg("user-$index", MessageRole.USER) } + + ChatMessage("tool", MessageRole.TOOL, "opaque", toolName = "terminal"), + searchState = search, + ) + + composeTestRule.onNodeWithTag("fullbleed_tool_row").assertIsDisplayed() + composeTestRule.onNodeWithText("terminal").assertIsDisplayed() + } + @Test fun userMessages_keepBubble_agentProse_isFullBleed() { render( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt index 0548af40..8ed80979 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt @@ -1,5 +1,38 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.ui.chat.fullbleed.isSyntheticSystemRow +import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker +import com.m57.hermescontrol.ui.chat.tool.ToolViewCache + +/** The rendered transcript surface where a search occurrence belongs. */ +enum class SearchTarget { + CONTENT, + REASONING, + TOOL, +} + +/** Search only the tool header and collapsed summary; raw payload is hidden until expanded. */ +internal fun searchableToolText(message: ChatMessage): String { + val view = ToolViewCache.getOrParse(message.content, message.toolName, message.isToolRunning) + val summary = view?.let { composeToolSummaryLines(it, message.toolName) } + return listOfNotNull( + (view?.serverDisplayName ?: message.toolName)?.takeIf { it.isNotBlank() }, + summary?.first, + summary?.second, + message.progressPreview?.takeIf { message.isToolRunning && it.isNotBlank() }, + ).joinToString("\n") +} + +internal fun searchTextFor( + message: ChatMessage, + target: SearchTarget, +): String = + when (target) { + SearchTarget.CONTENT -> message.content + SearchTarget.REASONING -> message.reasoningText + SearchTarget.TOOL -> searchableToolText(message) + } + /** * One search hit: which message it lives in and where in that message's * visible content the word starts (character offset). The offset lets the @@ -8,6 +41,7 @@ package com.m57.hermescontrol.ui.chat data class SearchMatch( val messageIndex: Int, val contentOffset: Int, + val target: SearchTarget = SearchTarget.CONTENT, ) /** Result of a search scan, including the exact total before any cap. */ @@ -24,28 +58,11 @@ data class SearchResult( */ class ChatSearchController { /** - * Find all WORD OCCURRENCES of [query] in the conversation's VISIBLE - * text — user bubbles and agent prose only — flattened into one entry per - * occurrence, carrying each hit's character offset in its message. - * - * Tool rows, system events, and reasoning text are excluded: - * - they are not visible prose the user is looking for, so matches there - * were invisible (nothing highlighted) yet still navigated/scrolled to - * - tool payloads dominate the chat's byte weight, so scanning them made - * search slow on long sessions - * Reasoning lives in [ChatMessage.reasoningText] (not `content`), so it - * was never matched — this keeps it that way explicitly. - * - * Matching is a literal, allocation-light [String.indexOf] scan - * (`ignoreCase = true`) instead of a regex — queries are always quoted - * literally, so regex was pure overhead: one MatchResult allocation per - * hit plus a compiled Pattern per query. Scanning the ORIGINAL content - * keeps offsets exact (no lowercase transform, which can change string - * length on some scripts). - * - * Result is capped at [MAX_SEARCH_MATCHES] entries to bound allocations - * and navigation cost on degenerate queries (single letter, "the"); - * [SearchResult.totalMatches] still reports the exact total. + * Find literal occurrences in rendered user/assistant prose, reasoning, + * and the visible tool header/summary. Never scan hidden raw tool payloads. + * Offsets are relative to each target surface, not the whole message. + * Scans the original strings so case folding cannot shift offsets; stores + * at most [MAX_SEARCH_MATCHES] hits while retaining the exact total. */ fun findMatches( messages: List, @@ -54,19 +71,43 @@ class ChatSearchController { if (query.isBlank()) return SearchResult(emptyList(), 0, capped = false) val result = mutableListOf() var total = 0 - for ((idx, message) in messages.withIndex()) { - if (message.role != MessageRole.USER && message.role != MessageRole.ASSISTANT) continue + + fun scan( + index: Int, + text: String, + target: SearchTarget, + ) { var from = 0 while (true) { - val hit = message.content.indexOf(query, from, ignoreCase = true) + val hit = text.indexOf(query, from, ignoreCase = true) if (hit < 0) break total++ - if (result.size < MAX_SEARCH_MATCHES) { - result.add(SearchMatch(idx, hit)) - } + if (result.size < MAX_SEARCH_MATCHES) result.add(SearchMatch(index, hit, target)) from = hit + query.length } } + for ((idx, message) in messages.withIndex()) { + when (message.role) { + MessageRole.USER -> { + if (!message.isTimelineMarker() && !message.isSyntheticSystemRow()) { + scan(idx, message.content, SearchTarget.CONTENT) + } + } + + MessageRole.ASSISTANT -> { + if (message.reasoningText.isNotBlank()) scan(idx, message.reasoningText, SearchTarget.REASONING) + if (message.content.isNotBlank()) scan(idx, message.content, SearchTarget.CONTENT) + } + + MessageRole.TOOL -> { + scan(idx, searchableToolText(message), SearchTarget.TOOL) + } + + MessageRole.SYSTEM -> { + // System rows are not part of in-chat search. + } + } + } return SearchResult(result, total, capped = total > MAX_SEARCH_MATCHES) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegate.kt index 913de983..97453ff5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegate.kt @@ -74,6 +74,7 @@ class ChatSearchDelegate( searchState.matchIndices = result.matches.map { m -> m.messageIndex } searchState.matchOffsets = result.matches.map { m -> m.contentOffset } + searchState.matchTargets = result.matches.map { m -> m.target } searchState.matchTotal = result.totalMatches searchState.matchCapped = result.capped searchState.currentIndex = if (result.matches.isNotEmpty()) 0 else -1 @@ -106,6 +107,7 @@ class ChatSearchDelegate( private fun resetMatches() { searchState.matchIndices = emptyList() searchState.matchOffsets = emptyList() + searchState.matchTargets = emptyList() searchState.matchTotal = 0 searchState.matchCapped = false searchState.currentIndex = -1 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchHighlight.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchHighlight.kt new file mode 100644 index 00000000..09720764 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchHighlight.kt @@ -0,0 +1,39 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.onColorFor + +/** Highlight literal search hits in plain transcript labels, reasoning, and tool summaries. */ +internal fun buildHighlightedString( + text: String, + query: String, + isCurrentMatch: Boolean = false, + statusColors: HermesStatusColors, +): AnnotatedString { + if (query.isEmpty()) return AnnotatedString(text) + val (highlightColor, highlightText) = + if (isCurrentMatch) { + statusColors.warning to statusColors.onWarning + } else { + statusColors.warningContainer to onColorFor(statusColors.warningContainer) + } + return buildAnnotatedString { + var i = 0 + while (i < text.length) { + val matchStart = text.indexOf(query, i, ignoreCase = true) + if (matchStart == -1) { + append(text.substring(i)) + break + } + append(text.substring(i, matchStart)) + withStyle(SpanStyle(background = highlightColor, color = highlightText)) { + append(text.substring(matchStart, matchStart + query.length)) + } + i = matchStart + query.length + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchState.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchState.kt index fd6d4114..c5eb0116 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchState.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchState.kt @@ -34,6 +34,10 @@ class ChatSearchState { var matchOffsets by mutableStateOf(emptyList()) internal set + /** Parallel to [matchIndices]: rendered surface for each occurrence. */ + var matchTargets by mutableStateOf(emptyList()) + internal set + var currentIndex by mutableStateOf(-1) internal set 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 03bf817d..5195c84e 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 @@ -78,9 +78,9 @@ private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""") private fun bidiTextDirection(isRtl: Boolean): TextDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr /** - * Renders chat assistant text as Markdown — but ONLY once the message has finished streaming. - * While [isStreaming] is true we show the raw text to avoid flicker / re-parse churn, then swap - * to the formatted view on completion (and for all historical/restored messages). + * Renders chat assistant text as Markdown, including while it streams. The + * upstream token buffer coalesces deltas; parsing is memoized for identical text + * so completion does not swap a plain-text row for a differently sized block tree. * * Supports: fenced ```code``` blocks (horizontal scroll + copy), inline `code`, **bold**, *italic*, * ***bold italic***, ~~strike~~, ==highlight==, ^sup^ / ~sub~, keys, headings, @@ -99,25 +99,8 @@ fun MarkdownText( ) { val statusColors = LocalHermesStatusColors.current val highlights = searchHighlightColors(statusColors) - if (isStreaming) { - val isRtl = remember(text) { BidiUtils.isRtlText(text) } - val streamingDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current - CompositionLocalProvider(LocalLayoutDirection provides streamingDirection) { - Text( - text = if (isRtl) BidiUtils.anchorTrailingRtl(text) else text, - color = textColor, - style = - MaterialTheme.typography.bodyMedium.copy( - textDirection = bidiTextDirection(isRtl), - ), - modifier = modifier, - ) - } - return - } - val linkColor = MaterialTheme.colorScheme.primary - val blocks = remember(text) { parseBlocks(text) } + val blocks = remember(text, isStreaming) { parseBlocks(text) } val latexMeasurer = rememberLatexMeasurer() Column(modifier = modifier.fillMaxWidth()) { 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 67901cdf..e4a578b8 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 @@ -125,6 +125,8 @@ internal fun composeToolSummaryLines( internal fun ToolBubble( message: ChatMessage, modifier: Modifier = Modifier, + searchQuery: String = "", + isCurrentMatch: Boolean = false, ) { var expanded by remember { mutableStateOf(false) } var showRawJson by remember { mutableStateOf(false) } @@ -169,12 +171,26 @@ internal fun ToolBubble( .padding(horizontal = 10.dp, vertical = 6.dp), ) { // ── Header row: icon + tool name ── - ToolBubbleHeader(message, config, contentColor, statusColors, view?.serverDisplayName) + ToolBubbleHeader( + message = message, + config = config, + contentColor = contentColor, + statusColors = statusColors, + displayName = view?.serverDisplayName, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + ) // ── Tool progress preview (tool.progress) ── if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) { Text( - text = message.progressPreview, + text = + buildHighlightedString( + message.progressPreview, + searchQuery, + isCurrentMatch, + statusColors, + ), style = MaterialTheme.typography.bodySmall.copy( color = contentColor.copy(alpha = 0.7f), @@ -208,7 +224,7 @@ internal fun ToolBubble( ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = firstLine, + text = buildHighlightedString(firstLine, searchQuery, isCurrentMatch, statusColors), style = MaterialTheme.typography.bodySmall.copy( color = contentColor.copy(alpha = 0.7f), @@ -221,7 +237,7 @@ internal fun ToolBubble( } if (secondLine != null) { Text( - text = secondLine, + text = buildHighlightedString(secondLine, searchQuery, isCurrentMatch, statusColors), style = MaterialTheme.typography.bodySmall.copy( color = contentColor.copy(alpha = 0.5f), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt index e5262dbc..63da8c1b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt @@ -32,6 +32,8 @@ internal fun ToolBubbleHeader( contentColor: Color, statusColors: HermesStatusColors, displayName: String? = null, + searchQuery: String = "", + isCurrentMatch: Boolean = false, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -66,7 +68,13 @@ internal fun ToolBubbleHeader( } Text( - text = displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback), + text = + buildHighlightedString( + displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback), + searchQuery, + isCurrentMatch, + statusColors, + ), style = MaterialTheme.typography.labelMedium.copy( color = contentColor, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt index d4a73087..c2de9bba 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt @@ -49,19 +49,14 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.theme.DarkOnSurface -import com.m57.hermescontrol.theme.HermesStatusColors import com.m57.hermescontrol.theme.LightOnSurface import com.m57.hermescontrol.theme.LocalHermesStatusColors -import com.m57.hermescontrol.theme.onColorFor import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback import com.m57.hermescontrol.util.BidiUtils import kotlinx.coroutines.launch @@ -255,41 +250,3 @@ fun UserBubble( } } } - -/** - * Build an AnnotatedString with search matches highlighted. - */ -private fun buildHighlightedString( - text: String, - query: String, - isCurrentMatch: Boolean = false, - statusColors: HermesStatusColors, -): AnnotatedString { - val (highlightColor, highlightText) = - if (isCurrentMatch) { - statusColors.warning to statusColors.onWarning - } else { - statusColors.warningContainer to onColorFor(statusColors.warningContainer) - } - return buildAnnotatedString { - var i = 0 - while (i < text.length) { - val matchEnd = text.indexOf(query, i, ignoreCase = true) - if (matchEnd == -1) { - // No more matches — append the rest - append(text.substring(i)) - i = text.length - } else { - // Append text before the match - if (matchEnd > i) { - append(text.substring(i, matchEnd)) - } - // Append the match highlighted - withStyle(SpanStyle(background = highlightColor, color = highlightText)) { - append(text.substring(matchEnd, matchEnd + query.length)) - } - i = matchEnd + query.length - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt index 6f1517d9..14879136 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -54,7 +55,9 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.chat.MarkdownText +import com.m57.hermescontrol.ui.chat.buildHighlightedString /** * Collapsible card showing the assistant's reasoning trace @@ -73,13 +76,25 @@ fun ReasoningCard( reasoningText: String, isStreaming: Boolean = false, modifier: Modifier = Modifier, + searchQuery: String = "", + isCurrentMatch: Boolean = false, + searchOffset: Int = 0, ) { val context = LocalContext.current var expanded by remember { mutableStateOf(false) } + LaunchedEffect(isCurrentMatch, searchQuery) { + if (isCurrentMatch && searchQuery.isNotBlank()) expanded = true + } var fullHeight by remember { mutableStateOf(false) } // Copy feedback: briefly show "Copied" then revert var copied by rememberCopyFeedback() val scrollState = rememberScrollState() + LaunchedEffect(expanded, isCurrentMatch, searchQuery, searchOffset, scrollState.maxValue) { + if (expanded && isCurrentMatch && searchQuery.isNotBlank() && scrollState.maxValue > 0) { + val fraction = searchOffset.toFloat() / reasoningText.length.coerceAtLeast(1) + scrollState.scrollTo((scrollState.maxValue * fraction).toInt()) + } + } val stepCount = remember(reasoningText) { reasoningText.count { it == '\n' } + 1 } val capHeight = with(LocalConfiguration.current) { (screenHeightDp * 0.4f).dp } @@ -150,8 +165,9 @@ fun ReasoningCard( }, ) if (isStreaming) { + val statusColors = LocalHermesStatusColors.current Text( - text = reasoningText, + text = buildHighlightedString(reasoningText, searchQuery, isCurrentMatch, statusColors), style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = contentModifier, @@ -160,6 +176,8 @@ fun ReasoningCard( MarkdownText( text = reasoningText, textColor = MaterialTheme.colorScheme.onSurfaceVariant, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, modifier = contentModifier, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/ChatSearchScrollEffect.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/ChatSearchScrollEffect.kt new file mode 100644 index 00000000..2b460802 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/ChatSearchScrollEffect.kt @@ -0,0 +1,42 @@ +package com.m57.hermescontrol.ui.chat.fullbleed + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.ChatSearchState +import com.m57.hermescontrol.ui.chat.components.ChatScrollController +import com.m57.hermescontrol.ui.chat.searchTextFor + +/** Resolve a search hit against emitted lazy rows, including hoisted reasoning and tool rows. */ +@Composable +internal fun ChatSearchScrollEffect( + searchState: ChatSearchState, + messages: List, + renderedFirstId: String?, + turns: List, + scrollController: ChatScrollController, +) { + LaunchedEffect( + searchState.isActive, + searchState.currentIndex, + searchState.matchIndices, + searchState.matchOffsets, + searchState.matchTargets, + renderedFirstId, + ) { + val index = searchState.currentIndex + if (!searchState.isActive || index !in searchState.matchIndices.indices) return@LaunchedEffect + val messageIndex = searchState.matchIndices[index] + val message = messages.getOrNull(messageIndex) ?: return@LaunchedEffect + val target = searchState.matchTargets.getOrNull(index) ?: return@LaunchedEffect + val match = + com.m57.hermescontrol.ui.chat.SearchMatch( + messageIndex = messageIndex, + contentOffset = searchState.matchOffsets.getOrElse(index) { 0 }, + target = target, + ) + // A hit in a staged history prefix is retried when the prefix enters layout. + val lazyIndex = searchMatchToLazyIndex(turns, messages, match) ?: return@LaunchedEffect + scrollController.scrollToSearchMatch(lazyIndex, match.contentOffset, searchTextFor(message, target).length) + } +} 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 c49c0eae..eca2f7a0 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 @@ -35,6 +35,7 @@ import com.m57.hermescontrol.theme.LocalChatFontScale import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatSearchState import com.m57.hermescontrol.ui.chat.ImageViewerModel +import com.m57.hermescontrol.ui.chat.SearchTarget import com.m57.hermescontrol.ui.chat.ToolCallDivider import com.m57.hermescontrol.ui.chat.UserBubble import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch @@ -260,32 +261,13 @@ fun FullBleedChatList( } } - // Scroll the current search match into view, word-focused. Lives here - // (not in ChatLifecycleEffects) because only this composable knows - // the message-id → lazy-item-index mapping. Reads search fields in - // the effect (not the body), so only this effect restarts on change. - LaunchedEffect( - searchState.isActive, - searchState.currentIndex, - searchState.matchIndices, - searchState.matchOffsets, - renderedMessages.firstOrNull()?.id, - ) { - if (searchState.isActive && - searchState.currentIndex >= 0 && - searchState.currentIndex < searchState.matchIndices.size - ) { - val messageIndex = searchState.matchIndices[searchState.currentIndex] - if (messageIndex < 0 || messageIndex >= messages.size) return@LaunchedEffect - // Search indices refer to incoming messages; resolve by id into the - // rendered rows. A hit in a staged prefix is retried when it becomes visible. - val lazyIndexById = messageIdToLazyIndex(turns) - val lazyIndex = lazyIndexById[messages[messageIndex].id] ?: return@LaunchedEffect - val contentOffset = searchState.matchOffsets.getOrElse(searchState.currentIndex) { 0 } - val contentLength = messages[messageIndex].content.length - scrollController.scrollToSearchMatch(lazyIndex, contentOffset, contentLength) - } - } + ChatSearchScrollEffect( + searchState = searchState, + messages = messages, + renderedFirstId = renderedMessages.firstOrNull()?.id, + turns = turns, + scrollController = scrollController, + ) val currentDensity = LocalDensity.current val chatFontScale = LocalChatFontScale.current @@ -352,6 +334,22 @@ fun FullBleedChatList( ReasoningCard( reasoningText = reasoning.reasoningText, isStreaming = reasoning.isStreaming, + searchQuery = + if (reasoning.id in + searchState.matchedIds + ) { + searchState.query + } else { + "" + }, + isCurrentMatch = + searchState.currentMatchId == reasoning.id && + searchState.matchTargets.getOrNull(searchState.currentIndex) == + SearchTarget.REASONING, + searchOffset = + searchState.matchOffsets.getOrElse( + searchState.currentIndex, + ) { 0 }, ) } } @@ -373,6 +371,22 @@ fun FullBleedChatList( ReasoningCard( reasoningText = proseMessage.reasoningText, isStreaming = proseMessage.isStreaming, + searchQuery = + if (proseMessage.id in searchState.matchedIds) { + searchState.query + } else { + "" + }, + isCurrentMatch = + searchState.currentMatchId == proseMessage.id && + searchState.matchTargets.getOrNull( + searchState.currentIndex, + ) == + SearchTarget.REASONING, + searchOffset = + searchState.matchOffsets.getOrElse( + searchState.currentIndex, + ) { 0 }, ) } } @@ -407,8 +421,11 @@ fun FullBleedChatList( "" }, isCurrentMatch = - searchState.currentMatchId != null && - searchState.currentMatchId == proseMessage.id, + searchState.currentMatchId == proseMessage.id && + searchState.matchTargets.getOrNull( + searchState.currentIndex, + ) == + SearchTarget.CONTENT, showReasoning = !hoistedReasoning, onOpenAttachment = actions.onOpenAttachment, onSaveAttachment = actions.onSaveAttachment, @@ -436,7 +453,21 @@ fun FullBleedChatList( contentType = FullBleedContentType.TOOL, ) { Column(modifier = Modifier.padding(bottom = 6.dp)) { - FullBleedToolRow(toolMessage) + FullBleedToolRow( + message = toolMessage, + searchQuery = + if (toolMessage.id in searchState.matchedIds) { + searchState.query + } else { + "" + }, + isCurrentMatch = + searchState.currentMatchId == toolMessage.id && + searchState.matchTargets.getOrNull( + searchState.currentIndex, + ) == + SearchTarget.TOOL, + ) milestone?.let { count -> ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedToolRow.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedToolRow.kt index 534daafc..3762dc57 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedToolRow.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedToolRow.kt @@ -24,6 +24,8 @@ import com.m57.hermescontrol.ui.chat.ToolBubble internal fun FullBleedToolRow( message: ChatMessage, modifier: Modifier = Modifier, + searchQuery: String = "", + isCurrentMatch: Boolean = false, ) { Box( modifier = @@ -32,7 +34,7 @@ internal fun FullBleedToolRow( .padding(start = 16.dp, end = 16.dp) .testTag("fullbleed_tool_row"), ) { - ToolBubble(message) + ToolBubble(message, searchQuery = searchQuery, isCurrentMatch = isCurrentMatch) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt index fafc2ac3..cf5a8057 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt @@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.chat.fullbleed import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.SearchMatch +import com.m57.hermescontrol.ui.chat.SearchTarget /** * Turn model for the full-bleed chat renderer (issue #866). @@ -52,7 +54,7 @@ internal fun ChatMessage.hasVisibleAgentContent(): Boolean = content.isNotBlank( private const val MAX_ITERATIONS_SYSTEM_MARKER = "You've reached the maximum number of tool-calling iterations allowed." -private fun ChatMessage.isSyntheticSystemRow(): Boolean = +internal fun ChatMessage.isSyntheticSystemRow(): Boolean = role == MessageRole.USER && displayKind == null && content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) @@ -186,6 +188,21 @@ fun messageIdToLazyIndex( } }.toMap() +fun searchMatchToLazyIndex( + turns: List, + messages: List, + match: SearchMatch, +): Int? { + val message = messages.getOrNull(match.messageIndex) ?: return null + val prefix = + when (match.target) { + SearchTarget.CONTENT -> if (message.role == MessageRole.USER) "user" else "prose" + SearchTarget.REASONING -> "reasoning" + SearchTarget.TOOL -> "tool" + } + return fullBleedItemKeys(turns).indexOf("$prefix-${message.id}").takeIf { it >= 0 } +} + /** Lazy row identities, including hoisted reasoning and grouped tool/system entries. */ internal fun fullBleedItemKeys(turns: List): List = buildList { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchControllerTest.kt index b35bea74..100dad04 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchControllerTest.kt @@ -34,15 +34,25 @@ class ChatSearchControllerTest { } @Test - fun `excludes tool rows even when the payload contains the query`() { + fun `finds visible tool name without indexing hidden raw payload`() { val messages = listOf( message(MessageRole.USER, "check the logs"), - message(MessageRole.TOOL, "{\"result\": \"check the logs: all green\"}"), - message(MessageRole.ASSISTANT, "all good"), + message(MessageRole.TOOL, "unrendered private payload").copy(toolName = "terminal"), ) - assertEquals(listOf(SearchMatch(0, 0)), hits(controller.findMatches(messages, "check the logs"))) + assertEquals(listOf(SearchMatch(1, 0, SearchTarget.TOOL)), hits(controller.findMatches(messages, "terminal"))) + assertEquals(emptyList(), hits(controller.findMatches(messages, "private payload"))) + } + + @Test + fun `system marker carried as user does not yield an invisible hit`() { + val marker = + message( + MessageRole.USER, + "You've reached the maximum number of tool-calling iterations allowed.", + ) + assertEquals(emptyList(), hits(controller.findMatches(listOf(marker), "maximum"))) } @Test @@ -57,7 +67,7 @@ class ChatSearchControllerTest { } @Test - fun `excludes reasoning text that never made it to visible content`() { + fun `finds reasoning text independently of assistant prose`() { val messages = listOf( message( @@ -67,8 +77,10 @@ class ChatSearchControllerTest { ), ) - // Only the visible content is searchable — reasoning is invisible. - assertEquals(emptyList(), hits(controller.findMatches(messages, "deployment"))) + assertEquals( + listOf(SearchMatch(0, 19, SearchTarget.REASONING)), + hits(controller.findMatches(messages, "deployment")), + ) assertEquals(listOf(SearchMatch(0, 12)), hits(controller.findMatches(messages, "answer"))) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegateTest.kt index a72289c0..5eaf44f2 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegateTest.kt @@ -139,6 +139,39 @@ class ChatSearchDelegateTest { assertEquals(0, delegate.searchState.currentIndex) } + @Test + fun `search tracks target kind while navigating reasoning tool and prose`() = + runTest { + val uiState = + MutableStateFlow( + ChatUiState( + messages = + listOf( + ChatMessage("m0", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"), + ChatMessage("m1", MessageRole.TOOL, "opaque", toolName = "thinking_tool"), + ), + ), + ) + val delegate = + ChatSearchDelegate( + scope = backgroundScope, + uiState = uiState, + dispatcher = StandardTestDispatcher(testScheduler), + debounceMs = 150, + ) + + delegate.setSearchQuery("thinking") + advanceTimeBy(150) + runCurrent() + assertEquals(listOf(SearchTarget.REASONING, SearchTarget.TOOL), delegate.searchState.matchTargets) + assertEquals("m0", delegate.searchState.currentMatchId) + delegate.navigateSearchMatch(1) + assertEquals("m1", delegate.searchState.currentMatchId) + assertEquals(SearchTarget.TOOL, delegate.searchState.matchTargets[delegate.searchState.currentIndex]) + delegate.clearSearch() + assertEquals(emptyList(), delegate.searchState.matchTargets) + } + @Test fun `clearSearch resets everything including matched sets`() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt index 0df28900..31834295 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.ui.chat.fullbleed.fullBleedItemKeys import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex +import com.m57.hermescontrol.ui.chat.fullbleed.searchMatchToLazyIndex import org.junit.Assert.assertEquals import org.junit.Test @@ -24,6 +25,21 @@ class FullBleedItemKeysTest { assertEquals(mapOf("u" to 2, "a" to 5), messageIdToLazyIndex(turns, leadingItems = 2)) } + @Test + fun searchTargetsResolveHoistedReasoningAndToolRows() { + val messages = + listOf( + ChatMessage("u", MessageRole.USER, "question"), + ChatMessage("t", MessageRole.TOOL, "output", toolName = "terminal"), + ChatMessage("a", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"), + ) + val turns = groupIntoTurns(messages) + + assertEquals(1, searchMatchToLazyIndex(turns, messages, SearchMatch(2, 0, SearchTarget.REASONING))) + assertEquals(2, searchMatchToLazyIndex(turns, messages, SearchMatch(1, 0, SearchTarget.TOOL))) + assertEquals(3, searchMatchToLazyIndex(turns, messages, SearchMatch(2, 0))) + } + @Test fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() { val tool = ChatMessage("live-tool", MessageRole.TOOL, "output") From cd992aa0b263f4e17d09405458c4cba6ba73f432 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:51:12 +0300 Subject: [PATCH 053/107] Add funding usernames for GitHub and Ko-fi Updated funding model with GitHub and Ko-fi usernames. --- .github/FUNDING.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..18541822 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: hy4ri +ko_fi: m_5_7 From 9aefc5bfc78d8c36296fd816adf175b028ee7681 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:57:35 +0300 Subject: [PATCH 054/107] refactor(chat): pure server message mapper (#1363) --- .../ui/chat/ChatReadNotificationTest.kt | 10 ++- .../ui/chat/ChatServerMessageMapper.kt | 33 ++++--- .../hermescontrol/ui/chat/ChatViewModel.kt | 21 +++-- .../chat/ChatServerMessageMapperPurityTest.kt | 62 ++++++++++++++ .../ReadNotificationReviewRegressionTest.kt | 85 +++++++++++++++++-- 5 files changed, 179 insertions(+), 32 deletions(-) create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationTest.kt index 946125fc..29fe3dac 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationTest.kt @@ -251,7 +251,15 @@ class ChatReadNotificationTest { SessionMessage(id = 10, role = "assistant", content = JsonPrimitive("Done")), SessionMessage(id = 52, role = "assistant", content = JsonPrimitive("Done")), ) - val messages = mapServerMessages(sessionId, history, 0, true, emptyList(), context = context) + val messages = + mapServerMessages( + sessionId, + history, + 0, + true, + emptyList(), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) assertEquals("Older duplicate must stay unmapped", null, messages[0].completionId) assertEquals("Exact row must receive the completion id", "comp-exact-row", messages[1].completionId) 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 1e67bfc2..56040ff8 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 @@ -1,20 +1,24 @@ package com.m57.hermescontrol.ui.chat -import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource import com.m57.hermescontrol.data.model.SessionMessage -import com.m57.hermescontrol.data.remote.GatewayFileClient -import com.m57.hermescontrol.notification.ReplyNotificationTracker +import com.m57.hermescontrol.notification.ReplyNotificationTarget import com.m57.hermescontrol.ui.chat.tool.ToolJson import kotlinx.serialization.json.JsonObject /** * Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s. * - * Reads the supplied transcript snapshot and recovers notification identity through - * ReplyNotificationTracker's synchronized lookup. Safe on the history dispatcher; - * it never mutates ViewModel state or dismisses a notification. + * Pure: every input is plain data. Callers resolve the active reply-notification + * target and the gateway media URL builder, so this never touches Android, + * AuthManager, or the notification tracker (#1337). + * + * @param activeReplyTarget the reply notification currently posted, if any; its + * durable server row id reserves that row's completion identity. + * @param mediaUrl builds an authenticated gateway URL for a `MEDIA:` path, or null + * when no gateway is configured. + * @param nowMs fallback timestamp for rows without one. */ internal fun mapServerMessages( sessionId: String, @@ -24,7 +28,9 @@ internal fun mapServerMessages( liveMessages: List, isPagingOlder: Boolean = false, stableRowIds: Boolean = latestPaging, - context: android.content.Context? = null, + activeReplyTarget: ReplyNotificationTarget? = null, + mediaUrl: (path: String) -> String? = { null }, + nowMs: Long = System.currentTimeMillis(), ): List { val existingById = liveMessages.associateBy { it.canonicalRestId ?: it.id } val liveByExactId = @@ -55,7 +61,7 @@ internal fun mapServerMessages( if (!isPagingOlder) { // #129: reserve durable identity before matching any repeated live prose. // REST-only recovery remains fail-closed when the target row is absent. - val activeTarget = ReplyNotificationTracker.getActiveTarget(context)?.takeIf { it.sessionId == sessionId } + val activeTarget = activeReplyTarget?.takeIf { it.sessionId == sessionId } val durableTarget = activeTarget?.takeIf { it.serverMessageId != null } if (durableTarget != null) { val exactIndex = @@ -179,7 +185,7 @@ internal fun mapServerMessages( ?.times(1000) ?.toLong() ?: existingById[restId]?.timestamp - ?: System.currentTimeMillis() + ?: nowMs val rawContent = if (role == MessageRole.TOOL && msg.display_metadata != null) { @@ -229,18 +235,11 @@ internal fun mapServerMessages( if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) { val items = HostMediaExtractor.extract(rawContent) if (items.isNotEmpty()) { - val baseUrl = AuthManager.getBaseUrl() - val token = AuthManager.getToken().orEmpty() finalContent = HostMediaExtractor.strip(rawContent) attachments = items .mapNotNull { item -> - val url = - GatewayFileClient.buildMediaUrl( - baseUrl, - token, - item.path, - ) ?: return@mapNotNull null + val url = mediaUrl(item.path) ?: return@mapNotNull null Attachment( uri = url, name = mediaNameFromPath(item.path), 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 14763576..59284f8b 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 @@ -21,6 +21,7 @@ import com.m57.hermescontrol.data.model.parseContextBreakdown import com.m57.hermescontrol.data.model.parseUsageSnapshot import com.m57.hermescontrol.data.model.reasoningSupport import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.safeApiCall @@ -38,6 +39,7 @@ import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.toAny import com.m57.hermescontrol.data.ws.toJsonElement +import com.m57.hermescontrol.notification.ReplyNotificationTracker import com.m57.hermescontrol.notification.captureTurnBoundary import com.m57.hermescontrol.notification.correlationScopeId import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptUiState @@ -2249,6 +2251,10 @@ class ChatViewModel( // ── Send message ───────────────────────────────────────────────────── + /** Authenticated gateway URL for a host `MEDIA:` path; kept out of the pure mapper (#1337). */ + private fun gatewayMediaUrl(path: String): String? = + GatewayFileClient.buildMediaUrl(AuthManager.getBaseUrl(), AuthManager.getToken().orEmpty(), path) + private fun sendScope(): String = listOf( AuthManager.getBaseUrl(), @@ -4242,7 +4248,8 @@ class ChatViewModel( offset = 0, latestPaging = latestPaging, liveMessages = emptyList(), - context = getApplication(), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()), + mediaUrl = ::gatewayMediaUrl, ) } _uiState.update { it.copy(messages = replacementMessages) } @@ -4736,7 +4743,8 @@ class ChatViewModel( liveMessages = _uiState.value.messages, isPagingOlder = true, stableRowIds = true, - context = getApplication(), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()), + mediaUrl = ::gatewayMediaUrl, ) } if (!valid()) return@launch @@ -5046,7 +5054,8 @@ class ChatViewModel( serverOffset, useLatest, current, - context = getApplication(), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()), + mediaUrl = ::gatewayMediaUrl, ) } ?: return@launch persistHistoryPage(page, sessionId) @@ -5503,7 +5512,8 @@ class ChatViewModel( useLatest, current, isPagingOlder = true, - context = getApplication(), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()), + mediaUrl = ::gatewayMediaUrl, ) } ?: return@launch persistHistoryPage(page, sessionId) @@ -5584,7 +5594,8 @@ class ChatViewModel( current, // Sync fetches recent replies, so confirm live completion identities. isPagingOlder = false, - context = getApplication(), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()), + mediaUrl = ::gatewayMediaUrl, ) } ?: return@launch persistHistoryPage(page, sessionId) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt new file mode 100644 index 00000000..81feb1bf --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt @@ -0,0 +1,62 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.SessionMessage +import com.m57.hermescontrol.notification.ReplyNotificationTarget +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Issue #1337: the REST mapper runs on plain inputs, with no Android, AuthManager, or tracker state. */ +class ChatServerMessageMapperPurityTest { + private fun assistant( + id: Int, + text: String, + ) = SessionMessage(id = id, role = "assistant", content = JsonPrimitive(text)) + + @Test + fun mediaAttachmentsUseTheInjectedUrlBuilder() { + val mapped = + mapServerMessages( + "s", + listOf(assistant(1, "Chart MEDIA:/opt/hermes/chart.png")), + 0, + true, + emptyList(), + mediaUrl = { path -> "gw://$path" }, + ).single() + + assertEquals("Chart", mapped.content.trim()) + assertEquals(listOf("gw:///opt/hermes/chart.png"), mapped.attachments?.map { it.uri }) + } + + @Test + fun mediaWithoutAGatewayStripsTheTokenAndAddsNoAttachment() { + val mapped = + mapServerMessages("s", listOf(assistant(1, "Chart MEDIA:/opt/hermes/chart.png")), 0, true, emptyList()) + .single() + + assertEquals("Chart", mapped.content.trim()) + assertNull(mapped.attachments) + } + + @Test + fun activeReplyTargetIsAPlainInputScopedToItsSession() { + val rows = listOf(assistant(100, "Done"), assistant(200, "Done")) + val target = ReplyNotificationTarget("default", "s", "comp", 1L, serverMessageId = 100) + + val bound = mapServerMessages("s", rows, 0, true, emptyList(), activeReplyTarget = target) + val otherSession = + mapServerMessages("s", rows, 0, true, emptyList(), activeReplyTarget = target.copy(sessionId = "x")) + + assertEquals(listOf("comp", null), bound.map { it.completionId }) + assertEquals(listOf(null, null), otherSession.map { it.completionId }) + } + + @Test + fun rowsWithoutTimestampsUseTheInjectedClock() { + val mapped = mapServerMessages("s", listOf(assistant(1, "Hi")), 0, true, emptyList(), nowMs = 42L) + + assertEquals(42L, mapped.single().timestamp) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt index b06cbaba..30c3b379 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt @@ -169,11 +169,29 @@ class ReadNotificationReviewRegressionTest { SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")), ) for (latest in listOf(true, false)) { - val mapped = mapServerMessages("session", rows, 10, latest, listOf(live), context = context) + val mapped = + mapServerMessages( + "session", + rows, + 10, + latest, + listOf(live), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) assertEquals(listOf("comp", null), mapped.map { it.completionId }) val merged = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true) assertEquals(if (latest) "rest-session-100" else "rest-session-10", merged.single { it.id == "ws" }.restId) - assertNull(mapServerMessages("session", rows.takeLast(1), 11, latest, listOf(live)).single().completionId) + // Target row absent from this page: the reserved completion must fail closed, not rebind by text. + val targetRowAbsent = + mapServerMessages( + "session", + rows.takeLast(1), + 11, + latest, + listOf(live), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) + assertNull(targetRowAbsent.single().completionId) } } @@ -214,6 +232,7 @@ class ReadNotificationReviewRegressionTest { 0, true, current, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(), ) val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true).single() assertEquals("ws", merged.id) @@ -256,7 +275,7 @@ class ReadNotificationReviewRegressionTest { latestPaging = true, liveMessages = current, isPagingOlder = false, - context = context, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), ) val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true) @@ -685,7 +704,15 @@ class ReadNotificationReviewRegressionTest { ) val mapped = - mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + mapServerMessages( + "session", + history, + 0, + true, + emptyList(), + isPagingOlder = false, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) assertEquals("Older message must have null completionId", null, mapped[0].completionId) assertEquals("Newest message must receive target completionId", "comp-newest-done", mapped[1].completionId) @@ -747,7 +774,15 @@ class ReadNotificationReviewRegressionTest { ) val mapped = - mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + mapServerMessages( + "session", + history, + 0, + true, + emptyList(), + isPagingOlder = false, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) assertEquals("comp-long-2", mapped[0].completionId) assertEquals(null, mapped[1].completionId) @@ -796,7 +831,15 @@ class ReadNotificationReviewRegressionTest { ) val mapped = - mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + mapServerMessages( + "session", + history, + 0, + true, + emptyList(), + isPagingOlder = false, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) val row = mapped.single() assertEquals("comp-media-cold", row.completionId) assertEquals("Here is the image", row.content) @@ -839,7 +882,15 @@ class ReadNotificationReviewRegressionTest { ) val mapped = - mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + mapServerMessages( + "session", + history, + 0, + true, + emptyList(), + isPagingOlder = false, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) val row = mapped.single() assertEquals("comp-media-only-cold", row.completionId) assertEquals("", row.content) @@ -887,7 +938,15 @@ class ReadNotificationReviewRegressionTest { ) val mapped = - mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + mapServerMessages( + "session", + history, + 0, + true, + emptyList(), + isPagingOlder = false, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) assertEquals("comp-media-dup-2", mapped[0].completionId) assertEquals(null, mapped[1].completionId) @@ -955,7 +1014,15 @@ class ReadNotificationReviewRegressionTest { } val mapped = - mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + mapServerMessages( + "session", + history, + 0, + true, + emptyList(), + isPagingOlder = false, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) assertEquals(listOf(null, null, "comp-turn"), mapped.map { it.completionId }) assertFalse( From 12ddef09a0994e2620b07417fea7eafe4ae6cdbf Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:26:13 +0300 Subject: [PATCH 055/107] refactor(chat): single owner for display kinds and REST row ids (#1364) --- .../data/local/ChatMessageEntity.kt | 4 +- .../ui/chat/ChatClarifyDelegate.kt | 2 +- .../hermescontrol/ui/chat/ChatListEntries.kt | 2 +- .../m57/hermescontrol/ui/chat/ChatMessage.kt | 2 +- .../ui/chat/ChatMessageIdentity.kt | 40 +++++++++++++++++++ .../ui/chat/ChatServerMessageMapper.kt | 6 +-- .../ui/chat/ChatTranscriptMerge.kt | 18 ++++----- .../hermescontrol/ui/chat/ChatViewModel.kt | 6 +-- .../ui/chat/fullbleed/FullBleedTurns.kt | 9 ++--- .../ui/chat/fullbleed/TimelineMarkerChip.kt | 17 ++++---- .../ui/chat/ChatMessageIdentityTest.kt | 34 ++++++++++++++++ 11 files changed, 107 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentity.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentityTest.kt 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 9b645e7a..5ea8ee9f 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 @@ -66,7 +66,9 @@ internal fun canonicalMessageOrder( id: String, sessionId: String, ): Long? { - val prefix = "rest-$sessionId-" + val prefix = + com.m57.hermescontrol.ui.chat.RestMessageId + .sessionPrefix(sessionId) if (!id.startsWith(prefix)) return null val suffix = id.removePrefix(prefix) return suffix.takeIf { it.isNotEmpty() && it.all { char -> char in '0'..'9' } }?.toLongOrNull() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt index 92bce3f6..aa031ec6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt @@ -84,7 +84,7 @@ class ChatClarifyDelegate( ChatMessage( role = MessageRole.USER, content = displayContent, - displayKind = "clarify_response", + displayKind = DisplayKind.CLARIFY_RESPONSE, ) uiState.update { state -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt index 53d45508..69732b47 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt @@ -36,7 +36,7 @@ private const val MAX_ITERATIONS_SYSTEM_MARKER = */ internal fun ChatMessage.isUserTurnBoundary(): Boolean = role == MessageRole.USER && - (displayKind == null || displayKind == "steer" || displayKind == "clarify_response") && + (displayKind == null || displayKind == DisplayKind.STEER || displayKind == DisplayKind.CLARIFY_RESPONSE) && !content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) /** 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 badd3eb2..78fc4eaa 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 @@ -117,7 +117,7 @@ data class ChatMessage( /** 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-") } + get() = restId ?: id.takeIf(RestMessageId::isRest) /** A persisted RUNNING snapshot is not evidence of current tool activity. */ internal val ChatMessage.isToolRunning: Boolean diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentity.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentity.kt new file mode 100644 index 00000000..a4d782c3 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentity.kt @@ -0,0 +1,40 @@ +package com.m57.hermescontrol.ui.chat + +/** + * Known `display_kind` wire values (#1336). [ChatMessage.displayKind] stays a raw + * string because the backend can add kinds; these names keep the ones the app + * interprets in one place. + */ +internal object DisplayKind { + const val STEER = "steer" + const val CLARIFY_RESPONSE = "clarify_response" + const val LOCAL_FEEDBACK = "local_feedback" + const val MODEL_SWITCH = "model_switch" + const val PERSONALITY_SWITCH = "personality_switch" + const val AUTO_CONTINUE = "auto_continue" + const val ASYNC_DELEGATION_COMPLETE = "async_delegation_complete" + const val SKILL_INVOCATION = "skill_invocation" + const val INTERNAL_NOTIFICATION = "internal_notification" + const val MAX_ITERATIONS_REACHED = "max_iterations_reached" + + /** Kinds that tag real user or local rows rather than timeline markers. */ + val nonMarkerKinds = setOf(STEER, CLARIFY_RESPONSE, LOCAL_FEEDBACK) +} + +/** + * The single owner of the REST transcript row-id format (#1336): + * `rest--`, where rowKey is the server row id under newest-anchored + * paging or an absolute position under legacy paging. + */ +internal object RestMessageId { + private const val PREFIX = "rest-" + + fun of( + sessionId: String, + rowKey: Any, + ): String = "$PREFIX$sessionId-$rowKey" + + fun sessionPrefix(sessionId: String): String = "$PREFIX$sessionId-" + + fun isRest(id: String): Boolean = id.startsWith(PREFIX) +} 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 56040ff8..53bfd0d8 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 @@ -51,9 +51,9 @@ internal fun mapServerMessages( fun restIdAt(index: Int): String = if (stableRowIds) { - "rest-$sessionId-${requireNotNull(messages[index].id) { "Latest transcript row has no stable id" }}" + RestMessageId.of(sessionId, requireNotNull(messages[index].id) { "Latest transcript row has no stable id" }) } else { - "rest-$sessionId-${offset + index}" + RestMessageId.of(sessionId, offset + index) } val wsCompletionIdByRestIndex = mutableMapOf() @@ -278,7 +278,7 @@ internal fun mapServerMessages( } // REST echoes must not reserve a match before the richer WS copy of that tool. - val liveTools = liveMessages.filter { it.role == MessageRole.TOOL && !it.id.startsWith("rest-") } + val liveTools = liveMessages.filter { it.role == MessageRole.TOOL && !RestMessageId.isRest(it.id) } val mappedTools = mapped.filter { it.role == MessageRole.TOOL } val matches = matchTranscriptMessages(mappedTools, liveTools) val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] } 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 afe8769e..9ebae364 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 @@ -174,12 +174,12 @@ internal class TranscriptComparison( // Rows generated only by Mobile never have a canonical REST counterpart. // Do not let coincidentally identical server text claim their identity. if (a.role == MessageRole.ASSISTANT && - (a.displayKind == "local_feedback" || b.displayKind == "local_feedback") + (a.displayKind == DisplayKind.LOCAL_FEEDBACK || b.displayKind == DisplayKind.LOCAL_FEEDBACK) ) { return false } if (a.role == MessageRole.USER && - (a.displayKind == "clarify_response" || b.displayKind == "clarify_response") + (a.displayKind == DisplayKind.CLARIFY_RESPONSE || b.displayKind == DisplayKind.CLARIFY_RESPONSE) ) { return false } @@ -323,8 +323,8 @@ internal fun dedupeCachedMessages( confirmedOnly: Boolean = false, ): List { val unique = messages.dedupeById() - val rest = unique.filter { it.id.startsWith("rest-") } - val live = unique.filterNot { it.id.startsWith("rest-") } + val rest = unique.filter { RestMessageId.isRest(it.id) } + val live = unique.filterNot { RestMessageId.isRest(it.id) } if (rest.isEmpty() || live.isEmpty()) return unique val matches = matchTranscriptMessages(rest, live).mapIndexed { index, match -> @@ -405,7 +405,7 @@ internal fun mergeCachedTranscriptPage( ) } - match.id.startsWith("rest-") && !message.id.startsWith("rest-") -> { + RestMessageId.isRest(match.id) && !RestMessageId.isRest(message.id) -> { message } @@ -463,7 +463,7 @@ internal fun mergeTranscriptWithLive( ) } - preserveLiveIds && match != null && !match.id.startsWith("rest-") -> { + preserveLiveIds && match != null && !RestMessageId.isRest(match.id) -> { val mergedContent = if (match.role == MessageRole.ASSISTANT && ChatVerifierFooter.split(match.content) != null && @@ -586,8 +586,8 @@ private fun List.inTranscriptOrder( internal fun ChatMessage.isPermanentlyLocal(): Boolean = role == MessageRole.SYSTEM || - (role == MessageRole.USER && (content.startsWith("/") || displayKind == "clarify_response")) || - (role == MessageRole.ASSISTANT && displayKind == "local_feedback") + (role == MessageRole.USER && (content.startsWith("/") || displayKind == DisplayKind.CLARIFY_RESPONSE)) || + (role == MessageRole.ASSISTANT && displayKind == DisplayKind.LOCAL_FEEDBACK) internal fun ChatMessage.isSessionStartMarker(): Boolean = role == MessageRole.SYSTEM && (content == "Session created" || content == "Session branched") @@ -635,6 +635,6 @@ internal fun mergeIncrementalTranscriptPage( internal fun serverMessageIndex( id: String, sessionId: String, -): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull() +): Int? = id.removePrefix(RestMessageId.sessionPrefix(sessionId)).takeIf { it != id }?.toIntOrNull() internal fun List.dedupeById(): List = associateBy { it.id }.values.toList() 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 59284f8b..01ba89f1 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 @@ -4136,7 +4136,7 @@ class ChatViewModel( ChatMessage( role = MessageRole.ASSISTANT, content = text, - displayKind = "local_feedback", + displayKind = DisplayKind.LOCAL_FEEDBACK, ) _uiState.update { it.copy(messages = it.messages + msg) } @@ -4748,7 +4748,7 @@ class ChatViewModel( ) } if (!valid()) return@launch - val targetId = "rest-$sessionId-$rowId" + val targetId = RestMessageId.of(sessionId, rowId) if (page.none { it.id == targetId || it.canonicalRestId == targetId }) { _timelineState.update { it.copy(windowErrorMessage = "The selected prompt is no longer available.") @@ -5106,7 +5106,7 @@ class ChatViewModel( // A mapped page can reuse a live WS message. Never overwrite its newer persisted // version with the snapshot used for mapping; WS owns persistence of those IDs. val pageIds = page.mapNotNull { it.canonicalRestId }.toSet() - val aliases = _uiState.value.messages.filter { it.restId in pageIds && !it.id.startsWith("rest-") } + val aliases = _uiState.value.messages.filter { it.restId in pageIds && !RestMessageId.isRest(it.id) } withContext(historyDispatcher) { repo.persistMessages( page.mapNotNull { message -> message.canonicalRestId?.let { message.copy(id = it, restId = null) } }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt index cf5a8057..74c96e32 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.chat.fullbleed import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.DisplayKind import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.SearchMatch import com.m57.hermescontrol.ui.chat.SearchTarget @@ -59,11 +60,7 @@ internal fun ChatMessage.isSyntheticSystemRow(): Boolean = displayKind == null && content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) -internal fun ChatMessage.isTimelineMarker(): Boolean = - displayKind != null && - displayKind != "steer" && - displayKind != "clarify_response" && - displayKind != "local_feedback" +internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind !in DisplayKind.nonMarkerKinds /** * Split a flat message list into turns for the full-bleed renderer. @@ -99,7 +96,7 @@ fun groupIntoTurns(messages: List): List { message.isSyntheticSystemRow() -> { agentEntries += - AgentEntry.SystemEvent(message.copy(displayKind = "max_iterations_reached")) + AgentEntry.SystemEvent(message.copy(displayKind = DisplayKind.MAX_ITERATIONS_REACHED)) } message.role == MessageRole.USER -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt index 88c2d033..f2168a95 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.DisplayKind /** * Friendly label resource for a `display_kind` timeline marker (issue #904). @@ -24,13 +25,13 @@ import com.m57.hermescontrol.ui.chat.ChatMessage */ internal fun timelineMarkerLabelRes(kind: String?): Int? = when (kind) { - "model_switch" -> R.string.timeline_marker_model_switch - "personality_switch" -> R.string.timeline_marker_personality_switch - "auto_continue" -> R.string.timeline_marker_auto_continue - "async_delegation_complete" -> R.string.timeline_marker_delegation_complete - "skill_invocation" -> R.string.timeline_marker_skill_invocation - "internal_notification" -> R.string.timeline_marker_internal_notification - "max_iterations_reached" -> R.string.timeline_marker_max_iterations + DisplayKind.MODEL_SWITCH -> R.string.timeline_marker_model_switch + DisplayKind.PERSONALITY_SWITCH -> R.string.timeline_marker_personality_switch + DisplayKind.AUTO_CONTINUE -> R.string.timeline_marker_auto_continue + DisplayKind.ASYNC_DELEGATION_COMPLETE -> R.string.timeline_marker_delegation_complete + DisplayKind.SKILL_INVOCATION -> R.string.timeline_marker_skill_invocation + DisplayKind.INTERNAL_NOTIFICATION -> R.string.timeline_marker_internal_notification + DisplayKind.MAX_ITERATIONS_REACHED -> R.string.timeline_marker_max_iterations else -> null } @@ -63,7 +64,7 @@ internal fun TimelineMarkerChip( ) { val labelRes = timelineMarkerLabelRes(message.displayKind) ?: return val text = - if (message.displayKind == "model_switch") { + if (message.displayKind == DisplayKind.MODEL_SWITCH) { val model = markerModelFromContent(message.content) if (model != null) { stringResource(R.string.timeline_marker_model_switch_to, model) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentityTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentityTest.kt new file mode 100644 index 00000000..a612d9a7 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentityTest.kt @@ -0,0 +1,34 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.local.canonicalMessageOrder +import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Issue #1336: one owner for the REST row-id format and the known display kinds. */ +class ChatMessageIdentityTest { + @Test + fun restIdsRoundTripThroughTheSingleFormatOwner() { + val id = RestMessageId.of("s-1", 42) + + assertEquals("rest-s-1-42", id) + assertTrue(RestMessageId.isRest(id)) + assertFalse(RestMessageId.isRest("ws-42")) + assertEquals(42L, canonicalMessageOrder(id, "s-1")) + assertNull(canonicalMessageOrder(RestMessageId.of("other", 42), "s-1")) + assertEquals(id, ChatMessage(id = id, role = MessageRole.USER, content = "").canonicalRestId) + } + + @Test + fun onlyUnknownOrMarkerKindsAreTimelineMarkers() { + fun kind(value: String?) = ChatMessage(role = MessageRole.USER, content = "x", displayKind = value) + + DisplayKind.nonMarkerKinds.forEach { assertFalse(it, kind(it).isTimelineMarker()) } + assertTrue(kind(DisplayKind.MODEL_SWITCH).isTimelineMarker()) + assertTrue(kind("future_backend_kind").isTimelineMarker()) + assertFalse(kind(null).isTimelineMarker()) + } +} From d086ae40a414068955eef48d1c173e60917e1369 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:35:02 +0300 Subject: [PATCH 056/107] chore: bump gradle-wrapper from 9.7.1 to 9.8.0 (#1352) 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/wrapper/gradle-wrapper.jar | Bin 47505 -> 47623 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew.bat | 52 ++++++++++++++++++----- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index eddabd2eef8d94a5437d6168ff9c87a78ff725b3..5097068a8d375f5bf0d15693fb5b3615c972a801 100644 GIT binary patch delta 38775 zcmXuK^Fw8i_dPt5akFl=ZP#SmwrzJO-E4ERYqD#yZQGt)Gu3xq@6Yr6a{hpG*4k_B zwbx0!hX{XwsIx-`dBlkz^s}H!JO^!(!a4_cTrz1Vit0f%cf_7G!OU||i2QW2#e#C- zzyJ9BnWX6jB;z;@A#xbj^p}xgYBF1L-QasiTtd%1&-L{#*7B0w{DPG`H4sf6OLlaD9UJ$J#D8)8}jKNALLb1 zU0I7uKrr=D7(Gs#Z9ug1TmZWvpWE2dgKZZPIjz{uFs77LDD@kBi&YH+`X0 zsIVIR#QonJD(;>{!T9>+i`M_WAte@~J=Lv~Bs(8qD=_s28~6%#FQp?cK<0Zw5~{-51f5N6UETWMv{ zQuF8lvs$Y(`FQ*^nzJ+p#`cye!XgkEVf`||N)^@WbmO?Q%iLvzp=PNijEF{>g~l=T z7Wa)FEgws|>LK(`Y$e=j58d-*8e@*}yGL>{TdG@d%Ot{q`XnP^4o`1`Y`d>_WK)P=BBQF}Uvel)T3 z;i+n01J&T7JqljFG*K!D>{fW!3-7hjiF%JIOX~PGSw+}cvCCEOGGGjx%)LRji-{THu_v6HH+JQ$E!FpCqPyoQd2UV2H19x?af?P_{4 z^!4qRiStDg2A&XL0A;e)f7p2s@!Bzv=j}1EoXZ2e2T0W$iFJX8?d?sG6#xmZqL*0Cwc8| zCnrChgue)g$L$^L?M2`D4IFB!NpEO#R(GhdFzndzGNBL}VGHhHA8Peu37|H$PlOaU z(}SulXP+REmea+$o{|1f3x%X28|fKzRfCIVRCYEUmKL5{jieMpMq)i1SsI4WgUvb%TJM*57;1DTkl z7$dFVChZ%PzjW9(36c(czy6utLCEK5V?Z0I>=NwE7?hn6e*l9Gv;=?Q04`E*w#;z! zrnOU7C*Y9;NidwX#qtB{k$mL<6>$+$EmjDRN!hjEva)20aHd8JiOA_CTjFH0*dMG& zL?LJ0nlUKujac5>qo?Nu6zz)fwKIpqgoM@}zb2kiyZ3NdI@ka5iglH+Q3x2(XY`6W z`9qy;|8d6t|onHpOjq_m)n8OTIJ=ur;XF|x`EWeUVwc{6 z5j!{dW6ulp9WQK<@|+t3l;XRt2n}9jwzTQ=yLOunxNe`}ecpJE|M4ZKR{@;PY`BT% zSBlxr#XOnBR%>lRS!AtQQ;Wa3#Hd!ILn-2^2Mp#9rmETmsWey49_LCtC2D)E%u@tar`SZEytAZtT}+k+#0`)EbU#- z3APhgE$LH`<*8>?M>5ZN{^3CXAzXkix5qQXz)_I%i*2YA6XU=U%#9?Ph!P4H30_!C zEE|Qr>1ngPsdeX9C-5K0SE;|OGkBZT<|q1k-1P096UHvkb+qrV1w{MwT|XLoy+*9F4}24O}tP%O;N;* zHkv{M0J&IuYy>)r?kz9U)=VW8QBQqk)1-apD;%bOpe)1_L1$l_~#d9)6meo*cUJ z$HNXNfcjFlh)Vu>A48!@)jP*z#5On?X^X~(kEgHhXKI!=J$$8z%YG2g{urDjZc9!5 z(D(U_7_^*1+oMN551u6N2!Lj_lF#H`6V5(BOv;re*sY4}Z*A)%nY=d|6)surqujV2 z?79J*-&Y*LOG?}UqKjKk&v)j+_VHW3F6J5#&BTJToB zNE7qP!SKN`)KJv!UVZul7_dlA+0nO}CiK%{{ueUKzo#Eq65f3tC88AJ4CPe8Dy{Cd zCVYgUyYp;AMv1s(7Q?%%h{xcerZnr6dD5F`W2fj@@W~Rpz+a@0T zTnCVi7lsBf&@r3M0hrkIU0q}tu4QUI7-6|Vx*~tINg{gD7^`&5fzO={!-|U0qc316 zULLp(v*)@GfNM5#c@|oZK4Ll96om55$xv( z_ch6NRWrl}K+U}xy_+HnzS^TeD{dM!a)p_#-uB!>i{gYTHG0++TnJ-U0q+KI+rcn@ z7Ze&|<{1Xy|2o?t3#ZMu{RWCA&)cOp5A z>k_ufc)+GlG+44KePhd~V555NvNKDX=gQT3neGC;9#*W?0g(vHn;Y$~zti=N*W7+M zjYOt9mGo_gUygLuhi51tw$_x#sYQzaE-UXiEmRTwq$swxmcw31$8Pfog6oDTh%Z=Xl{2u_aXZ3p=qBQZ+xQ@9L0q2bJoe0_v(ZRS;%ct} zmKO1x(`c!iN%HOcV3cH*dn~#7zM6!S^^@235;Wtvis5_cI;$e>-s_PRm6I22oX}oKJL@n2ur-zGn^WbF`|Z9dn<&wLjoVN@8MR ziTjIODfN?fIQve8S!FA#*r*P3T8YyZt&pZgP!vfia?loXB2+a5sB@^mFIJqF`~LA4 zcFHC1K6YBh3e$H1B{j@|^ON0M2!Q*ByfLN5j6$d^SMqis%>{y}!?x5g<6`DVNtJ-*!JuRXb=6`>I3;2J8+)sbd%q+Prr0 zDSW2)xgyuKbbaL*j(&4-@*BK=M4g}h{A+axvBegmhnkbb(*2$pomU7(?;HGP)BssB@Kw;C|te5I@E zJp1jFcy2>;-9T){Bk^!|7gIB*f86{T6OTN(jf5|9sa~c!Qfa?%xR-+IBL{#hRQf6L z0GMSCZPq>!Bcz7Vc;Fy|uBIh?l4EjMJPrCN@74YMk9oju>W(3*+M;L>+wu` zDUB?Nyl5mCW*2ozRDMHiijsMm+aJ55u8ovZon)cuZi*%3!mIHfdCQk>)IFi-TulaB zB@_I`WhjBtUo z0lck(^sE()xl_#Kf|B9XK>{HZ@C^l5d08S^38rP&6OUfj)JeR|+#Ra%&+!9F? zD%`Xw`5vUIGnn3AbsL`f7l-ymi>28l`Fs$b#7)PntA+Gb=N{BI*_AT#>LFGYlw!K{6lVynO(vixLuj3X!G~c^H)ODv6x^(*H^A+{XTk8~wrff;RQusEstb zDbYJ5PM%}}49JA>U2gO+U0Ws5+0F(OGw+Hq<*w>-SRq7BvnoYft)%MFBm3x+ z!=R)b@FBYNN)P?XExRQ=*Q;rQt0$Iz^~mJdJkk6{{GY<9IlZNf!F>5*h5Wzmg&LbO z*_xCY)RC&#a;E+_Omp_#ygVNf7>gdj*~b%-=(TdqMR(<4!;fx8@%!_47zH1O(F^R0 z6z-3->F+whvvSw9D>Ki3^ndtWuiOv|e`P2bzO`|>(YOzBbas;(jq54QHs~(Ru3c!g zC(^F1=695u?Vz^r3lJf0Z8zd>4QoF@AuuvJ1G#SCaCYxh@pl=uipCE)uAFYNMm%Pb z2Y4B8G}X}y{Lr^4Xs_#XcGCIGfH70vxb9^ia+|;gJRAI6IrZr>iw4{1#d5B=v|Tna z&>FhV;B0RqPCPTNZJdVl`|m%le?w0yMe9`CVfP45{3l<+JKEqqfrI$$b>1xUR?`CpfZhWAbgw`bFOnFQ0+qh81gA zq9c>$uA%&tf4=uklry%m_z#YXGR5HD*W8?+dk_e8(+6Z?%c)YAt;4JF@ZKHzTY~Mf zeOPoTTIlPo65o#Mwcp}T!w+oRz&co3AQyuHN`2(yYIgZ{*R9l|oj^XODr!UwhvX=i zT{v9pwy;;0V+Xz%>u2&K_BE3X?IOKC7DA?K6aq2tpY0}yhi9u;9oaFK>d+k7#qCkF z=kyPypV})#J*^Xsn*3pn8uL`7 z3&bbfdTE$csuL#~Uj5ZoX_G_8rD(4h(VfrAVh%)2-9ygD`y z8>8|JDtP2j_lQ)eir<;PD{vo^iDO9HbSkA4y4d9VSoD&H%vD^sFlDAVlISTX_FM8; z?dzvY$PY(#*AN-sgC-EiN+%2o@-s=dXJ4Wu=*6<1i2L&`YthvAd&c*rqY z^PNC#_p{Mphc-FDPx9Ek*e$7@FjR59FN~+gw-Q}`a0VhYaN)aBy$cU4?mB;Im}!*G zmd}S~amFrxL*{X`8^}9$%-2OD&OHNU&DSEShRp&uNjoo4p+lhR+vE(qM1i6pR4FlwD(mu%Zq-VngJe1o6@dLLbE`m{QOaP+G7+BR^eyxdqk8~A`J zG=JaaOFT>etQ+FDJ`6gNl}Ec&orxRxsgm(fR1uDdGGHg}TC6PeuhZJSF`C!A33OKpG~YcQCbog-rQ2rq2lTI%hDC4y|*zR z9(1iCf~Z2fNufIWw=%D&bwD zZmA@JBbT1q<()U8LL@<>2jEc-&w!oZPXy;nHBLQ0WMz7R9-@^{O`ci&unHITP%$Dt zs3q$|?4*f(fyI#gvzy7*ovN!_G=TOyU(r3v75MYmXr9I=4FG^!J5(TKosif~&lPJG zXPU616b>3DjJzO}*D|5XX`A62{PDPQC|ihZaYU5@@y%2uG7xmR%mip| zmFQ)q`uj4=Q%sc<`vyi>_v9ai7J&lv$MCXq4G~0*84%@aC zD_sE=1F6QXS`vK(IcFnf=iY!RAl$(y@p@0k(m@*YdF9HA{k4td>H)~`XPMW{MGC#% z!rS(20~`R)5XuiSkCb&_>(RZKT=Z+TBY* zO5kK3$2DLFv7#B$hVLB{9S|M$(^r5xl?;+wxq)__~=EF*%4h}iaB0y?u^%UBxrx-HnGLN#}Uq}0Yn z|0DYCK~zf`Rd|=})+n>@yNM5S^SK=~eOp?ehQ1ZeJ3fewqoy{2cJr^2k9&w7ZuLJ? z+AA18tTaC(mm5Si)rwVZB53a-)rX!Y_H(lL)KCLFh>Vm+iUaz`LF)_AozEXNo6Bi@TmaxB~ zj@q-Ko%i?M#q=I3YI^w}L0RGj_<{f8FrEJkT_fm4L2{6+sJYq>cFnrNz7GxF$~FcP zVX8wR5=tdeSSZ^KosD+u_6_E(&}h&g)&HNWTl+Yec%$;iM^kc^#|QUUgkO*g#&?fi zUpszV^KM=h-Bp(!y=hLldH~_aFbXsT7m3gvmC520nQnaay>8~NMnYIxvV_uEm#b+& z<>3OA(duS1&xB4pq_sBwCx0Mm;emybGO6E(ujj7&5+Bu}hVvJfAP@G#5=Qe&6^voQ ze&`Nb2%RMZ3G_c1F%4ZXiq;4^9s4mDdKp4w<5Aif+Yv71%EBF0zYB!?jZ`M%wMf`l z?=c7^n@rFDLRlF8w@*Ztd(0vQdn~^LwDhrbN+nso>-{HAM5hYW)d}APpLtb583X-^ z={jjFQWdAUqm--gHt)XonS@pIp*)>BV%#EuVN#a^kt=eV-@X?oZhJt`Ol{!SJMWli zP-Gnf>AUhhdl13uP--(+)nG)H0?ttND;lY0P+{StyH4$PLGzXh1`65YR{eS+zGS4b6xBLD6dBy1q3hT(NgVW7pKTh-XBjFz2d0{2@Vp64_ z;zEOuv5({0Z-9sk?SBSxjPW}Ck+Igg+2OqTgBq8IFWf9Ijp*hOBV!asa=TVu8 zIPxMn(L!5CU#wJ(sa-y)MKoDu1ltQ#y)cwJ-*2}-1drr#aH_cuU}rh%tw){kY;snn z(Hpv)A@bfYZ@F~GU+c1~dt>UMc#|YWuy5YL3&XC=B;1On7pPvz%t9NOM!49p0km3Q znLz(WM%KD73FAufVnSe)h4KuWlz=3Tu*lmF?OH4W3)I(7Tt5tbd4H3T!xKb&g!#4{ zQFI+>S^yGRP!0rix~P+S-xnA$ryNp;s9osi^pZ8#P<-)Q1q44P)^TGEe}uFFm&qeU zoLsYUw~At$=x;O`{pL}8hVlJnTY0(cDIi4dtZU983iWk#2Z_6bWubvnl?zSdwe^OS zzivx+A(YVUZGsVcZ37AXWh9aQFVR34H1}wuDknriZ90288dI(?2wA{ytPVy%rt+Ze zLn%$nhloV0y8AP7Zw)dg#Ri#0 zQ>b?k1%chV=_xsOE_(<||LV(vIqq7|ix)}ZFYuTm)5aKORCrtcj*QZQrB=s{z3(~H zJIy(C431qh*RWv=tE&CMb+}l{PBUvgh>Tt#OzhAqf)OXkw18yrlU_PqAjh;-cl$PFEUQ_Yk#7ENzKRctEs}un6s2NSA9~!uV-(ko!_GY#J~_xf3;3! zTlrZqsl9HA`Pe?R0fmNvTM*&V=UU$j?R6LhHPN{|DG@8ivLmn>z6WkE@eUjElwPRbOS^K;d ziJ%|@t5p+CgNxT!_>uV-bQ7&x{e4fL6fH`~6%V(kN>;Os#$oLPz`(NFzjV$DC{SN( z_w}+@Q5ds{wW;PndG>mQsRB3u#NojqE- zD+p6Ehozn`Zhz<@jpP8+$(73UUCetYJ|qHWSF8$98hvOJG2u++X-3{MuwKWCshKf7{qgGgGor^g+^Mr`YF< zL;36Oq?iE2x}K%11HNgRE?77!L~L-Hl$%1zNaBY}CnW(rIBq($ zxHo5)2BSdObXcCKjp0*qI>MVj;fr|)LtTNXlfl+C7XNp>{T1GErjNMD}7hPM>LPi633us*c_z`PbYD87UO*@_!{u|rRCf)F6b zW;zX@4~}zib`I~eOE7U@Om{^#M!rXX=ACM{jWvG9-+WVO)>ZI8c|J1s{>HTM5No=s zNUok+;3GgQ48h@Lk;>LAYp7id?ZDB2`R3}2mXr)w#Z5X$1u;0dx-Kve=v{A%(PzCp z{7v$_>F^@~&M=X@pm#Q;DHR?UCH^*YOv(D zI$_0#_51oj9*F7V5&i5^4q#Cxwyl3V0PU|XWl6`w^ut4*Y4`jFpS+Yy%se{+zY4Y8 z-m~cs^dQ9eOu>pttovt_4tQi0r)pB{>&MVM-x4I(GSqe`R&R5|E0G?E>b zK(iQFVteM??ZXz)3-J>TsWeRY`%xNI+KHJ-)j9%16=@h)?ODnaamgjBBD@+TMs6L1 zMwK>rxXW!eDw=HaO7e<1nK1%SA*@iYl2Z(K^p(T7{a_u%GzFbG?}qY8+1MNl)MjwTi zG@oUbJT1d^q`UAI+L^2)-LS$jmQ6HnyR183-DdH_ zxRlDOCunehnIKy{GmH1gFi!AlSiu*HP$0O&S#}k_mqk?MWo{>4=S&>US71i?eo z1p!1gbb>~+w{cQ45Zsl4gLM`czZ}fJ_j&i~%UP z-l2?Pm*ybQu}xIl+9ENHKp}~O3Q_W!^xJ6Xk0_dy$-xXP>lnMq@0JJ-We1FWI*70L zB!88UJeIxIhhk;ab;G`K$j)B|)k%}=cP-)V)17xQ;W6d|Tn8li$)W4R9r^#`o0T zql74sfB8-Fy`lV(}>2>1Gt=4>je!|s(>!iynQ(;^LOU@fqUbLmh=Y-auq*;GSa z5Be%1_a}J{mU%RqT8fT>8SpIUe)_dEtPVT_t2`j43+MJ=nlH|qy)D>uc^%PL0?qW@ z!Y&npn-CFf{hh+s;8nVaQ+ho?+dU4@k+cjP6H?~!T)0Pkdt#RWJtq3&MIYbGd0g5U z^ZckaGrII6u-?s|MM)G|FCcc@jhH1bCf?}R=6iy`0PO4v(3asE?%PAEy~-2bIGTb9 z8Y0ug?w_suAEl>WxLk|}ad!G)qi3=}{!%~zZ}FLdTdu_5aA%pO%Uhg&^ipYuc{Xg2 z(LCsT4o-Qfz*r#2C1}&NrxeCp#U~^nf0g3i}UkR#H3?6+9+M0|H~&gTFOl> z{v`@J@X7mpS|9^Ci2oWoSyh&=8b1Xu#Zt=kQb@2xpt>o5&_VGBmzR8efBGhCQ4?pC z&PhXq1HTE=;e~Dcw&(aww}<=PJkN~ad}qHz?_$D!?zXa$UyG5-Tky)bX(GT5k4)ge za#=(+i^n%~8LK#!%u(L5#F)0^Lu_7nu2}3kq7dOP2Fd7Ev&98~E^}jIwH`l?-{ob+ zCfnz!mQcGDTiw<@3=v46O2T>6Vs_xh;E_$S+JXx64w;hsyNhJqZupSJ@O&WAew#F` zPfEt+^Od-r%i=*_I}02!G$&Gz&QwgcQLW-vKQds=cdJGTA%!Z=#O{fTlXX+nA;%L< z(3n&~so+}#4+{t4^HZuTGZlR|MawQnJFK13DbF0{`bhzU>kvCiH?;^pdosa=TFfq@ zY!Oz$-C|O4=qdVusoqMfz^@DZpU52bD*B53E5jxKUl|UD7A3i+ffW?1vE_!Lf%&G6=Ki5R^)wWL8k&An5e(JiR)20G0%IIaRO6-UCfrbke z7C*TV-+8o?&^Z7U#*R0$)l`zgcOd3;lNZ0k`<%1G`}+Fh2K1#4rxoa^|0}Tc)3&-d zmemBu43f17Q*Ibgb`HwIMv))6uHcgpaaGa|{u7*nb^Q#)GKqO@t|Z2%ahbpw%h0>7 zaBUq)0d~rkh`M+M*>`{NeyUD2NV6~r{7{ye-Pu^_EMxXmfHsRb%*nEfNG_!W`V#!i zq4Vpqj!dzE?Qd*PrAwsq$bwC6BuXdQl${_5Ei=XyJE0VsRs&(TPE@Fc$@RlwR+B8s zJG%3*yZ>Eujtep{j(F>$pcqB{shWTF`;z>Z{%)q{>+UqQM0_8&1wtVp>g-2(M<0cl zo&QM~OeU>?R2DuL*lOOSJ)=$=!KB`@9{yPi*<@SwTVOS+ThU)*z72f6p}@HjdgX z$%HS;>bE|AiwVEr5Jv3~Qsr2OH($3nQelKySxlm_w##VK)b1##*i9&k!j~*`L#{N4 ze1g%$O2k3dwkI8`6S1@8>K;%f7Qo0-v#2t2kgT&%#s#{zn9XxnuZt(r!49%FU$0mb zSq+uPeCL^sdj}e8W6y9T^J0n+BdcM^?0;mzNMYM9 zRGq=KFX)q5&_!kG%4tMcJ^%@4mABzM>DzLxT98H_`oc8?=}(6mQ8h5I!wmYe=5=6LoZp^{gmXNQ7&k?$Ltx`ppK=|EO4wCRoyS@^-T zEIyVN(GFg6AW0>{wxu=oa^4exmUo{y=1sm}a8(a2d^ z=ejqpLU;!ULj1B@J<$#Iku=)BDYGr$bPz22YFc+V;p;#yx!0|FA^Vbs)bp-E_4&E{ zR^OEPi8RgWG<8{GrJ0fte@M??%&Ta_TwM$~^ z4VwNo!JTfHb}dXDPXz$1mT^?anwqtrk?OGG`d1YF zHOx+32pdoIqGCM-b0nhO@{X?-G^{xdkG% zER3sR(T7X%o|e^dEWv$niv4W(JZ5oHqXO~C%!a@LT7pf{f_1rh7^FxbwLp#Lc6E5; zua7&>n^SG30YkOnx1sbL%p8ph(Vg7Y5M^j51_9fy&_6g{QJ`!^xh9{*a;$0X%DJF} z-t$&0fAr9W>s^7#>&3vHneaG^t<9V6hKn~g**eEtF)yZ-|3}}d!=H3^eO2Jf3{6hZ z`xG9a=7Tm@Fh#6O7TxU|Rv|CEud^8)o$WiOP5ulK+{8F7@3=IBmYVQTv~SY+(}$ob$8Xc$p@+mcxoi)Yx|c6#61c@B8K zTlw|t1KF56(7Y}1YCZB@>)(-y@IaDkkw~?%U^&6|_%5qmz4u4vefQW6b4rlr%KuR7tHOhe*B)n_Q-|8Y17Crr&!%- zDRWrKo7G?jhWy-N(51VXbDu_Ktw~&sC^2^@qF>JilU^39>vt0kU0UmN;~+g3ayzza zfpXlefQ$m(u0{Wf;v`c2pQc-`tdk$zj@uJVc?*ssF=M;Zwx%?`1|drvMC6su1> z1G{Qr%yK!6R?%R(bJU9p2IMAnekJ32`F>xRv3(x{mBx@GJwKf$+AXr_-&4KPi}!p zeM@8c8Lp#@b)i{GcEMCM%RmPmAPtf9&uk3bk}5}JqDh1%y8&6HIT?>iQzR-Ym~oHJ zeu{gO7GMN2zFoJ}8LQ*ce~js@ms-8HSp6%kky`hIFYWuqf!p7yi&N+}dNSd4mwasI zeUe-qP9%+boL444%R9lCbf{-$_Cw|j@XS0 ztUKG$TIHg-`t@x~rxGY8XPAu=7e$C!QN{i@j_^N0^KJ3Yog2yu)N^Ujk>i>h^t%0- zgRI2prxvTn+PGP`YEoELz5j{8o0oH0SUSE4!S^ zR(|kuCv8)RfSJ&AFB{&ivk{pb^qlVLIuqVObg=bhdn0bfRqW&5Las*DEzIDBxtdzP9z7^ZN5RI~xGXGl~#8yCI z;8vOix-H&@b(iZ5mn*5A69LCdiGajjrliO9xC6s#6eHNhe2#@ly5coqh}RY-ut!>P zA_F<~^=3X5B6E>BgG5Ri6PINODch<%yQmyQ@EyTXkP|~J@U$*~CSO|T3=99N5RdoS zJLj60)EPlzi1T`chY4|oKGgM$-hC*#iUSH3_6>cjJG|RF8%&{e%ABQH%4!$Y-lpg~ zHF_%-pd*VN7oBml7ci$t%(93XRd}3g7&%h)G>+s?F)56#&t&zxqV6T;nRDLACv{SdlxPb(>Y!E@(A@sPuICVQ!HhfSrXNYPNjkB_0Z zl1zB12#1v`!yXiO#S4*kg!G}SQUbIXh(%>24rR=#h{&=L;uK9UQNo$NT0(;Kl~Bnj z7OeXbU{xS*GhSYTnyTPSvcC_Fb^KWNSnO*@m=#Ha&%a8fy(~)3L-7TJhGm23@8F1n z1Vw*6yX;X{W3*Xo-CR;UGqxGD8njx`Rs?lkv$ibgeWPXCSEZD|z!EK9r6{9O4gQ;) zw?k5*%GAC?c=jv4)scUw3L_q*zZSlPZj3VKI2xR8eyf|=OuGoRLpt9^E`;sBg0@oT z`x%;^=x@a_YuF!NF~D8+iM@7ipeOetUc7lOq}PAa9X#aON%;?UF?I0vu?9rnNDL|IP_I>EnUl^f#!RK7)49E34i zYV(}}6Jmui9Ll0GADdRTk|=5sqZ~cax7^pqr7Jq*STZ0V#9I!txRluURmea1 zjdW{`d?-MR=JSnPYF0k4(*!A69&ti=;Ve)Po^YEaQO+SLybGRS8nMAsdnDigX!|L_CN*0kV&dX zin|tn9_m5fZ@0svI=RCG)6_xB0b{+h3`eo6`Chl}+?0AE@4s}Pu~8JhGLw$XiyC*n zt6L*6*BK09dqQa{d3#tJ$4l0+lTY?mDo6M2y>)47@zLmjXNn{NwUSf>Y zm|1;TCzg^bbRL*$Z8|p^T;tg=VkH}EWjBEuKy~yj&p_Z7ly#c_cKm(UkNEdLjsKii z&H%yyx7SVWN)H>~<|cZc1p~h2N(Q_9FJWhWBHkXYvp;DhAQJsPF_CmZBF)J>me&3@ zVi4Yh!8O=)8n~J&M66DbX;!dtrESF*ie$@t&{=THDLI zy9P+qtc>q*We43SPZgk=HZ15kt16zUd`CBKY&!$n!c`j1&*+K^n!@6}fE3)gZWl`| zwHyynOvu-*Ds6L~4?1_jC$cgSGxFvSs|Iq2oFW=n za*wXlD4h%oWNsA(g$^`!W*B$!B&`n}4p5x3<>DsM`i_{3F++TkIDtWjtznn%H@_ub zsR>?@u5!{EOK-5QUokS)-e95@=++}^>ekd;_OMgPYDzIb8%_L|P6fYEk<_!%BrEO zFUF-CHk}Bz!omSxona6G z%tl?KiUJGwoBk9z!9o?;5&_!WTe3?H?N05A4g1(oVvmc|1PZ7d3CO)6Fu6E4(I}C^W;A%GNfb6hKCQl^!Bdzk+ovI?R13lq?;g$IN zm0Hg_fTr77C(#HxNz^;&AfkckSD2n-Jx|{tEoK&XgC>HBU-Ee|R9g(s<3U0_{is~~ z^myvlbeQ6t;-q?&_~DcgNN_p~3M8D6!kT=Q@6x2-EDBtI}rCv}maO5w_{T zxQ`G?gj!{*0B*HoS6SJf;aQWx8!Ha^y{sr1^YY}4rjtO+kd+k;{h=7J!6_1vW7;G; zM`vH1c3hi5)nQoI#C>0=WV-*Gi83snSHmd<IRH&+%nU;7?NC6XCx>LIn*XSlJBW|pkRfD%GHCQ3PW#){0Udm@wAU`--E($ zp=r_OjhssKC2DN5pF2mXDTJvd=(wk->iKTdN`Fcv;>@If%R5)1x{`z*7- zH|>I9XyQOTQiM|FlCNZ&<*a}4|I_>1$_;)v$S+@Hq5r4%LuzaweJhO*MYhK0>r0M5 z83KGV>xwP3TbJw((#eYJDfa93Et&T7myYu?kbBpcTU#>6OZ@CUO&rQ3qk3Vg#dnyT z&}bW3la3wI!Lq>-Q5uoj#1RN|WDy!7kQZLpxof!r93;mspPxCOpEIEClfU;v#DbqX zUw)?iz!C^NU+kFz#dD$PL>75kw(+j#0Bszd6wMdf&By5|8Z+bEJy)r=>(yFPTibao zkN&l}J@>79GN!quq^i}o9i66QJ&SAfrBrRIiE(l9dK@DBa;H-7$%oXVBFtKnsWr&B zyc{n#>*`-wG05N^tfCeBw-e>pf;Dukn(P+q!D^l>E+gZhBm1(8?aSZtbaqJixA1sz z+9TDqDXF+u>+J4>ikN^!I8A2fYOmuQ+Hx5SDJh3=?m_HTd^em{I6!}ZW>yZzkfq}} zGtby0O**9h_D!USrmLdoV*UNAHk(l$yX@cVun4UeZNHy_rdVoW2wh&&F7r;KKMn<^ zu!tBauDbF;+XqG3(xn}qdc2OhN%e>$jni{M=Ihhgh!AHb{$7;qWBrC7f`c@IFC?!-Ihj{ltE=`cEiL3wxp#-ZXdY0B98AXLY6iv# z;A@j>Z#FoBP$!(-zD#M;!%b~#)`3-9g0$e|`PH03iccGu)h^2c;=c>As}XlKo02+p zm%VhnC7z=Ro|UxJ1xNiSLUQ&ZiaPw>@4fx70fShm)Wx1(ygaiu3G#66zV-Y z4tOGfbVq#!rb_fYmq@M$;=TYLoSEZqT2&?ZBMjqv-_6;|@*3MMgyim75$^~G9GlI5 z7&dT4d^xw>8-Wyoa><>33(?-6f(fw}Po74G`+oqDKyJUb{67N3KwNO+wfx(2v4;XU zh(kftqd{+dO%Ne0)!AzqJY$7Iu9J~_;^bi7c1#RW7o&gbY_C{A7Yj;03|l21VQea~ zd^NQ(LFct3+^bu+6$@i@@?^quo+Ik4v3XHb7A6(IQKY%W-N4ZAnsYqMw3N3CW!*8s zm1)h}>JoeyP5?PDn2o$OVh=h=SEbV@2KV(=q^^L1U^Q&uxS*CQ>t`iM)2U9Xr=7qf znpy0^&TN0a5X9>|++!YYZ`SeRqBn@w;|;p&h7d`--$d+OAsvd4~HAizRP3QpiSfmXo(5q+@^Ly@J+ji#0IjWRGjMEHm`J0N#Tq zsk-5^leslumIXa1neEadyG3h2uo`3FsdBEj9IDu>#0{0SQaKjH2l1f*ei0vLMoJ$Y zPCEG@K8iaP^)bQPe%oP%k2rRr=wv9@&Vhlwgz~v;hQcM-3Pd9&J|097#{vw-UlI5V z*};E(v8uZ~hGV?z44zg+e}c8W!bNv+WW;vt;XTCylNS|ZTP#z*#$w0vh2NYV05JpSm`7OqGTXD640D3p4Xm6a!^|3`8OKBb zC7dTW*6?suRJupkEoiR!bd;w_d`6Y`TPzJk2`iN-vQx7)^=un;llfR3f8nB?uax_f zf?I#0=u)08;c@_<#}_Ca36*J2cPGA@BkW(}8j1o4O7(jQe~Mw#5n z%sPkBKW4%m)uWzEZKp>C@h6&2UcjGL$|vpEo2hbuuT(GWsfxfq7p#~oO?NSw8rC}V z7lLIAi0?VYUk30Z{)$1Mf4M!wa9d3Y=WQ<2qVfL*U(*wROPfSSvJT@srjhV>f{umm zxF)54P)gsJb8>-VHUH#4>dAl7Pfq|}RsKT@Th|d zTMNd2<9`CUf~&I{GBRvyXa6H6u0+w%wB@1Z_Cp?aOOTze1TZCwcp#bYB@2#I%oXh6 zp!hV0O^M$R{nG%@_?2ARU$6?r{Pr=DvR$6;zn@MZwGv>DA$7cMq3mJEb=-U0VK>S$ zM>%>m1f*UX0NWrsVq{S3%#?$$ArrfNK@Z_wH z-E*b9A#B8#I7|si$L`B!`uKtiN(&nwxmDIRz@F88Yl|=Ke|6GGw5XISoWyh6|>Sq!`5!QV2xM__AMt3VK-ivoD(cFhlQQM*AKUFOV5myZD=l)5 zn*LSZtJkd%tQ5zR%r2&Mvp|+R-2n=gG38!CTU8tEGVFSJ#!7V^@V0JeDc34!rRZ)u z=x%)O^w?DeXz>5g-o1=U7J3=UPU#IukKC`h{Ibqsf0b@-VJe37(f!H-x^2GfrH0Fh zy;eTY*Z8n0`?L~lzh)Me0miEgGJ1xrd@`ZLdNU(gQx14tZUqsPxZCB*L3)Gx$S~y) zD-Hva>Y7uWD(U}VP+lX41K22h=Y$Sy+t=6I*Ap4cYf5mNF;j-zo{J_5$}k^il%0_r z?6%qWe>st|?f9hhcPn?M@NJNe%&+(+_0YGc6glBHux+ zGaI%I@lIw7-PoT@=TdfWI$d;sW$+%>@^C;Le{9zepTl)}vhx_rd6woamOH-A@=sQ# z@X1r}VbU{5`lyl3cV?`#ZOWr8?*yh7AM+M1CoyKq>sfxfOyQ$vk!IjG5!_Onn1^ZT&SncLV$Mr4dA_mze2VjF zRE8SP`5tF6Yt+}cJ1!}Y^;+OK@M1}Yt6%Q*2s4-f85T!wZuNxbbVjb%BXJw zkx4YiqyBN+QbOB#+$MqnB}A*x!`o>=NoHag>M0k(of@S#rHd z-pTJl)J)+97&Qiqr-6;`zh(oQfFGjEz&0YLsH56>8@xI2eFUelmCDpVTmq(CokHHl z&k`)Ajs?fC2!HS8yjGtzp8oJYLI%fsCUGL(8b5TXghzKttb|j$OL*hf5>7{bCA>B2 zFX0{0n%1`V8^S(nq-GrNerDGs-V<+nf=YP*1(!%2HsI%YU86fT(M&onN7x-#;YN3i z;AVHc1-HASxJn2@rU;T{gSbZg0eBq<0IfIRp_-cF-FUD6L?ij~km+Ru{Fy7q|=p>Dak`t%b|VQK3cqU`RoIK2Awa z*F&7W6S@R)PikSG&U_@=b^&YDh|-1cx?>xywFd1jtfRv1=)x0(byTz7 zjzJgJQQ-%1*oAde_y`_$VI38A_*Ga(g+GXoxv-83e;iM{u#O6Uf{$K>byWB{Ot`R) z3V&Zbp z$gX&lp4d1kJ@LM&BdfU`Dm~+ldeXZj%e9!XY zM?>=J!zHnvLbOA}W|v+S3rT_*G9VN+K z6eqr`BzbbsT9N_>=BSz4um`nQv47Q&JyY1L`sTkA>K=yl06&0#`BkE%$2Y}@qibw1 z@FJJp)vJnN;b@|+Q9-&EX{Bp4(IV?v5bmMvU&|ej&~{JKCLiY337Gf_ZT4;MYt-Lq z%2oqby2bV-Efj}nxR4w>FP)26PB-py!4nlQ=7Nt@z}+tRx(e9mf~P8A)L-YqZ||N!uz?D6gU(*B~}VF>g)9f>(*NnjG@#~ zxpu`)CPv?C zzl)yaq4b0{P+^$f9M@KG4&@SNaGs%bqh-V%gZ*FFpPGr{3a*ZXF4Ck<{dBb^u9KIk(^7`H+CQ5w z;pPZ=s{#|nB21=mn_==Py(#T}3I_cnM(mUuTP8{<(|wn`6-9}SS$pHWWnF5p1BOH?|WkG*R!$1&*XN{WL zlh3etP;9!27D8`c^dy2pJx?~XY1&P8*-fe-7JMxaK7bD;PAtx0nE#vkf9Ct=^9um3 zVJzTdyM4^xWPy53Z7Jtc8#$5MHNLjk)vnDNR}%9;mM{`8ml!j>B)yJ(6Xo2XjWm(I z${O@pJ!D)MQ?@jFn)4<&~#f{5^(N9rK*6X zCG`@}dkX6APd@L|J=X2XjUL`hVPC-U3OA@Mwv~D)R;0TW4h5w2O1kejFgZP2FgSn| zh7xQU!O<{5!hH{tUr#d{ii;z}r@HtvfneSK=Yah>tnX}+~7;g|#)q4ZK^uhM@QQDQxf z8@n!5gSrGe!4~Cx!YS1|{A2qadORHKSy{7adzoVhy^P9+rS+Sbjj6VO^2 zZ2=a_m=8}rTC0Q#LNTA&!LATxF=k~^bG7Y6C6>rojHPUqZZ@t1)p=Gk;Uv@&?CNDn zlbUE9f1>xY5lU<%xGUdWEYs+wkjMH%@!8LJT!g6Lw*G!Kb)eD1&6~~*mW+j3;&Cl5;k>L;Gh`)PK#+tZG6znqHg&g# z896r`3+v~N)6J+$JJEu*GMccCFugCX_9^jff2a^P6$*oVX=?l@6LFPIhAXq|t+-G| z5jGI&-UFh9HkMA^&kn4!POa5~@r~G2f_A)*Fn5f}&B>S^j;byDgKD3N*+G~+UWnDn za3rL%8n+UBYm`__(>)FlG<66`cuv9fTaLG6dSvyThVYi^55FEN@ZaNVZ z_TPq(j4gt_dAUyA(I3;5{W%)y;RKO;=$78NwomC2);0ViET~*+#_T5ckC^U6L>$nB zsOfa=N>p`1GuQf99XlH?RpONnuoIOufAqyQR-hhM6Ao~UIoZo1ipbdU1i!8*An(a& zfJNVneIl&)6RJ**h#}79g2^k)Y-)-lJlTn>aE{oyn&8QC&0r+V`7#<+Vj({}2O}he z<6J8!ZIW>vuIDgTA`xxh*4UnywlB8P&?+Y*;>7cQ+*r&oAKH6 zSBr95`ylH}>ob$@*#!%ae{z1z(^wZD!iNPnADNQ-jTwu7l&zq|dy-KWeoKEJht$lR zV~u>nBtMQ%6ysy~Br|`R1E1nte_1lD>JEIGSDGF83>!DmpU~B)SD4s=yEum!WRiL~ z(&!H-xTt=Pu(Ctd6CTBL;kGu9e+lo&xJ19FT7;OoUy1fb)OwG%IodC_Rz>^wDv_jG z!y$1upAu3N!FV{MBUd3;xEJ>cUcZnn5e5fM@wn2@>HSM0Kof!=!je1<=SS!$+f4Aw z_==1{+&@vDh6{vpv?}32R^S+g^-7|bMV9bYZWv6Gtg^HC^C8=me?C6$5j-j! z<-i0&ye;7%3t%WDAwo5m+cjDwqGpq83TYXKFhp=y7ltt-;4v8wV_38~{s~-XKpvOz z5S}0`KT#-Du2$KEW-xaoyE6L{o+PZRp2BfW?3LpoCMY+<&(kuV!Z)(@*05bG=WCIr zBz%)|Us$~9T3kq#tDx4*e>$t@TX?nvNAW*|`V&fJ{^`&ao#{WHJvWb{gl}{9?grb@Rb1cWl{sDg! zD*F>**?Y&A@Mq3w8Fq!0o8m0tY1JpeOFkEW#ouK71#dF7>hlvd8GWv5sCD2UtkQ5S zr0(C?%|64*YjxsZe|Ssi>TTi6t&^$qKLX?(o`1AhQzn%YRu^m@-XpiiF~Zz(j)&9W zRxm4gB@1V-O0Er}n6o=IhZ7?HLQb+7WsZtCRcA1~7YhU_J9-%WG-WZ(9+rw_l12F} z32NMtgky;fYQK}5qE|me)A)R^gJ!VGxK49&$_nYEnRrp~f9En}XJNQ>=&F-u8A_&d z&Xo~0)}!|tG1=;**))fBMQ%Pxi?wT+3hktMLe68vLrkebC##f}7>l$(RQF@FP$X35 z@!XS9?1VrXtIkxx36&NTmYy7rL`yi9{Nrj_rZ%Ohs+g8ib#|P|2au>%raF=4XqAJ! zlcW>jX4EM3lP_)sAB?rtmmJ@ZW%AY3J#99r(V29XFddISGffF|TR&PslT&Un2J`6x zna-n=if$x-yz~T9z-Sp*MjHenfBs}^$RJU>=&y{c1*2DT(kAYAX(RE(GcAuzi8{>b zEy3|I+JBC3G(>TC(pK75%%0fEB{Lg#P6~*adziM1T$E^u_KCxGa+FaWUCdc25x$(` zYZr$RFYKO><8!3x5)eUFdQO?<=A%c67|CU9=pgQYIp>+IdbosoqREIJ=0NtCZBB!O zdSvPrv&+H^R7g`39x5j=ZgW$!zh&3fj+V{4)^BWUaZ-%iNQ#nXUW#NjKhB_2j3jg!#f6Dj{iWlnJ1EI6 z#AMZf*j^{?GXZ&avB%Y@wwD!rx#L`_0S)=Jo*of9?-!M4yXos6km*LciSWM8rp@iG?dv?PJu&X@LY`nUq4V@>YSknB z-6QF#P|^Wbg5Qa3c-506NSwT4vuf)AX`s9_2(z>$LR^t6dfVVEhKa-p|j#8dMf{aUSPC2 z;glxY+-R54lzd}i`!T_zITXJs(=&9GaLH6*djcDrh?&e$&&u>Pacd|C<0=>AX5&ue zoT*$UKc$Vo%%~IWk-o!S4*CuUTbM_KxM~EOlfG*v@Bih*S>TY>nx^yUsPqXp zFy<^yJf}nYuW_9EziVxC++ft1_lU#aAC)$hKoVTd&X$#Hl~bUjAl2Sr}Ja zepZ;U&X2`)pC|JP!|$Zm#AlAh^iytd#$ASGF@DaKWwgo6*%22mx!#ns#k~BIekIc{ z=+{m}%%|T9S$5=qWtjxqwA$}wdX-+!YZABgvH`hr{E@qbU@|VQG0mla@mCJ!Dzoth z{aL0z5f66d3Gp2%rt?IbC&b+9$+@7v(VGJO?}PCY@Rarjr111EwuA*2ahAhWwV(92^X9oo=pO|B;#tgxbn*6P^SGZ9hXr; zBY6X9K9po3&LL)c9hXCYG6~}ZIV9T$TdWqFY_V8)94-n*w1n!kNIXr_s}`24WsR1M zgpAZ>bWW;A?^UziQdXOu3!66QG*O|jxLxT=8f}(He+>?c%srdqQbunF$rF<^`6 z__)OqQ)GKG+NH)bE2U#mW=NXg(Y^>0#6K&F;o$#daq`s$4ZGG!CG)ZU|=`22W@HLAW}H(G+N_OQAW9_4R(S=|7Ckghz+5B`}O_ft_`` zTo7=svPRK-8wO=Ljr(p#rW@3R7;Hw~U(L_5`0`{5$#NI-J8Xlsby@CX z{Qojn!j(*+KaDF>xW@eP597MP&YkUP++aI|n~EMsT_9j}-O^!m-P)1DZLYdQxIMdi z2d@r@)z0k7oxJi{u@Z27o&jC=9Ksie@W3!03ixcpcsSsHJ&3QRa5#;xr*UK$PX&BM zUbiicXAa{zg3s=@9|mX60eaqDB%Z%dc#=u`q!fNMK=->P*Q%UClHv@1pn#87j-7T@|-$>!_zM{dA2YLUW#(sm?FI|5l^pRh9)tQEEVtW{ zqUi&eC5Z9!D0kDn8REWT0aa{3-RLe7&g3rMRYs+TH+8Wa@h0A2xl>d&fU>NtCL_m> zx_SK+Us2I=N4di-rD*QG#}BxR${n`dLfCSLA+67oo^=|3$9GN6Sq5U2KuOVj@qD`5 zCfM=}<4~YIO^aN2rl|5D)ugDN%~xbv8|%bV*4FkiYDm%Pf#uR&DD}Ge^^6pq-8ne& z62Fv7Zo9Qy5`x%myM>|cyUg7~!nf1IkA22lMtg^- zf0Er9#)_S(@KScy6xNW_#};R)SyV&LE00354Z>!B6`Q_dx5HhW)q}CZQCH+HHW!BI zT4ACahUvyYyU@lR9IRPw@WYNY-8_K#Uc>4VTm6H=>hxjL@G1IeeTqIYfHQ~bc1En_ zcn;JL(;e*ppH9=A2kG-0hUuO_o458L4W{Y-dbhpqF?tMmMC3!;Im+IWTrpk=Q;@wNjN6q1>hYlmr%Fx%&HNTiPS#Asm@Ulmcayg zCI214x&lCd$pVdVt}LF628sP<$dm1xKLTj>BtRk?8vu)@;&cQhu#axT+!V|6a+W0= zGB%jzyJ!^Ly@mNgCA4J(n}j}OQ7$GR&=z2S5d)U+(z~cJH!h$NE=M_vrW*yTM#(u_ zlosRJs5A#q&ozcSK>od$w;Eu>GA^&HVaIxg+s-9*4{NcH50A4TNjR||(=1nDy5$DU zu-uGN%dMDcc?~Yh8z{59i&@q=D7VhXDb_`pZEe6D>q@w->oM258}qE)IMsS3=3B3S z#RBWISZI9{9@{BcWLtm=TOAhL8c}IG8%u2GVX5sRRM~V?+xDZzb`5H6*Q3sM8}loR zG1{z# zTWdSYEG2d0f(3J69l=GSS6~CK67J5;LDSplFs$ZcE6ri6&10)AWUF1m?-rvVXW-wK z8T{7?qBN}Z06jtv{1;G52MEab+`5kv008(VlhGp_lWTb~e@$+}Fc5{u%jKgy z$qMz3IwO}-TRD^3wZ388H?GTDXDIPNOc*JJ8;rT$klw|&H{C~`? zt;}O{<1Uz>^ELsskD(bqwe|1+{*2 z$BrGE42A-xTRfm*>}vI1ZAtGl7>@;vAcLC(43a;<;15ts2MB5Dzz5EgaC%*TO-lnY z5Qg7a?P^_HTR$$|R>4+VS5cv;co9UQB3LN&m~OM%X!e6-TMwe(Px2sw2Y-M+N}R2V zhzuk%lV_el^8WGq24D}%437^FSIs-8$Gsa7^_>I9^PRfm#bF{9Ph}i6!niM}uZ0ra z2s#{a)9?nuyD`JiI0rsgM%0~uBxr;@X~@j6?fbp{PLEjK{F(QMN>O7Zv>_`snp^u@ zyZg@lG#Epn9S^l9PNfnI>#+{pK=Zy5?ycrYBD8xRnUpKF_Pw8E_gk1`n7`&D?kXMy z?q%;zu#T040n291CCfGzmSkethy@3;l0Hd`SG!^l+k!wjObDSl9g-RX zP9Uk$6jC5&C1b!eNevBYXj0M~4GC$;K?*6&)s#5s|C{&iC3)rapZ_1s&c65Ny*F=W z-pp^_nyve2@=fn~vEeTm_bXe^SBCgP35 ziGBBB{o{A**jsDl?o~78;1eQdV%;$YA4eWTY`FYn(GM(A&Sd7re=J z`S}BxSPUI|tz-(TqGMgtnx^yCEW6nxX7Y9?GRZz`b2Mf#ElhtT2b%|zk^Y#~ye}CU z8L^Vh?eSDP5{nt+WU9PAvNzHki^K<;xAk6c^`#ARGr41l!9gp@w5X%Z{0=?1p2_S_ z?2E?|k$$E#%Q3cZ%#DM0)<`5h)S60J!@V(Cd0Dv$SCsN#(n4#0Uo4Y~?$xJh?d!8r zsm@3|qGh|NbcKItJZ&Z8ky!Hp7@K2>zCBSKY153;vAFxfXgr#}nCaTu)1313=lau! zsJoKMQCoMVk1A=dmkg?ATL1qbxIr~unord}noV;&D&*T@ANgpO zd^?kAKKvv)FuF0CN++Ye8F9qziA0)dYi)&l+qcAphF z+9vJ`b^1!zGH4kTZ0*OMDSiH{eYApBdTBYGJ;mwljSQ+~s?oiWFV@_ah{fQGkThjb zJ(I^89!ZaOU^S+>wRIguPU&0kqaZbUsezin8bgnWfPJfK>#mu4L?4|)t3`@)ndVFz zGw6J#>f(RWt;u9W?`cqrm)45mDx#^4(Ig~GBu9O8fqkG54jAdj9jK}8n0}kq%fT3p<-R#s3ewJ|N2uAmK>Xsln1zX{u1rtJ~vFEMCfl}VRP zlOS(H?RxEA+Dw(zI1Ffu)4rE+GNrd2qNi(y%*EG+EXRK z*4wFDw%)VN5u2I1B%|I_ZRRdbb>dMM1~Ohs?Bw6nJR=o;E3ws<$w=2iJqrf`@< zVEBK}J?n0)J!qxHO6_&0dWw%C)GK@TfiD*AHOUh1f?#!JPQ3v)80L?RbV9K1bR>x= zvSPJCLtcuCr`An9d^|ENp(HHI&MPsk>=Q}KnF>Y@l<_QL8BGWtG0i< z4;EZKvul@M13K7C`@EE){kS3NghX(hDesMOh^nKr^n>?Cx=Hl)9;P#vG(U9!nCVW2nl}=(qS(>K%Ww*V#Np3Lq2+lc6q6e9%fox+jja$ z_E{0(uO&gD~h`@UNOIOBM#>0SSWqF1pqk*BwUMtXRS@A$RGK_yqj)<}^ znjUIe8E;F(2cpU0Kzb;W#)lPHRXVwQD1w{Pof%#g7)d5DF+CcT?En160z)Pq(v_bh;FBTpP-x|%%ea>&B%RL!@aWErj3g1xEZpWZO1CatP@U+WqGMh=BM@n7 zG_JcE_cf-0R!sptO%?Gu`g`&IFCiF|88_%FK6;jpdjURAAS`6!H%3R8&MKRX3%g!9 z1^!`1^VQK28%Tb&jF5jiZ-328FVa6DOj+@?Bw{B26ll09o*A~1+U+3qOZ3me|BWdd zm9QA}FSzQc$2t>9Yg5b`#;S-6-x5srGW{z8Q{390&J9e5DuopO9r~`Ye-GK?l;doI zn4<=L9}**?bRi@A=m!!>UZEcXkIh&UNX*%hj7YT?vEhw`${&9Lai<~!IwHwhOlSYE z5FLd4F}>!cf2W_w&H%6O;XQyUIP1^tkoP(wXkOhLG6Mq=h_+?YC0XP@pedxV(oUw! z{`?$Px)9`hlYS{l>_5ez<#}ue_%AQLLBB#e)!Dy9l;+xw^4gyuQWD64>?_1>E@l=+t>Tp!A=h=b}_YdC8p+^o!dJCDMVo;FT`PFi+qUhE8WFp zBcG_XE^R+g8SKGsNn}sJ+pa{sD-#2FTvw+2IlV{buMK}!u+Ph0o|Ug6b4M_E4(bC~ zw=EV);eJ<7xpcY{ldD+jYOc2LmCn#ygL}o5JfDf1O+Ev+7tC;yerx}>ff?$gb{{X~ zMTowEw}`}Rb8t}*AzuJ6$D#a%LE;-~-VhzkLpi<>fu*+H zX0GImy?ha0BBk76`PRx*S?Z1(kit>fms3_7ZxnwYM%`O^+4k0r9h-LV?B29}_m*v) zn@qk;F27da&Xkqhl8`Vsds=zJ?R>e1&*H7n<`vtwUB0Pphsm8-4%u7WuUOFYHk+78&mCkE|$$hZW~A^_?~uPBrYG z8%lqCyo=wBB+S>&aWP8I%GzlRl^kJOR@8VjzBjSQYVObicUtMlKt!v>U=*Yk!^IA! zua){qZXRX~CHl7jMWV`C(UC~($&8e_{xeP7F*;)Xd4u+rr=MGQ>S$Xe7VAblhoUSV z@Nz#7&T%u!K4)JFj3xsPw;o4h>A?{Sg*GiSr44&s-e%NKvn z(AaiZFEMPtaB95Ax8?U2%+NO!iN~#2N^7>wZWw$=9vz>;f;wZ5!FQs{P3|-KE-&9M z4=7$qAvPP+($ccN-1^Y;`}rPBWfa2(P=hQg0eGf)GLA_f6_fQq9+&e+`Jk8P^T&Ut=oHZ1k`(Z9MBG%&vPPtezFgS!fZ`&Bm-s+L8_Ry|idiA7E~T^tMEU+XY%OJFCcY<*OlI=ur#u=IYZ!k7&TD5<#+1 zeNhlS69<`q%H@zIKLsE<;|~M|KP~Td3FO&lsol%Z@NwLO?J$Q_yC(m=?S8rUCLe!U z5dT5`3c8`zK5Mg;?i&)XJY9bfkyjWdGWi7{4;pK8%u>V+qMm0$GI1N=3n zwSn5C1s4`8uIvv;6`2a!1#Bc9kl#O~8kS$Iq&5~`0oQP<&g8EHC284YYH2n!`JdsG zO^73DlfMbXwz1@~z~q;wJuS~z?3TLTzl!U9yI3U_g6An^pO?QYCu- z=AJrkC{zUo?r|3iWuK}*<*dBQhieqq9kUUy&;dway-^+@Y{!4fry)~S`Dlc05NWD? z{6&3dRZTw9PbuXJS63~VR`!lg~hwmNQ9AOf1BM-8R~2srSDz` z6?B}!pW}l-wW@#4qgJVUrgMuiq_n!7d9sd~D(F=!Riji_h3U4ax$$PNTCUE~7cSD@ z5sh0ubuNM!I?uz0RKNaW^c;|oVFz=`8m4N2h7+kKxvHjGXFt_nk$a`|sSv#ee4y6n zlb`+5n1pT1P!}PM@9Zwr1tnuR_9g04kGfd3>KEgV#6Ew(DP2UIU3)5bxhhPxsg0;< z6bcJtUlbr@U&>UdC(QhhJ*dt~{Xulrjf&Tb_sJ`nPqnMdWj@LZZ*Mf7OAe;$%!3;} z3n!Iing97tZNotYi~uIZqmWIP4C-C_Jy~s+SM;IbM8BzaAiS(yv&K|c%55z3%x0VB zDzCaig#~{j$S*+p$*$v+&2iRi)GpzC_mo%n+_0f`iwcnHHz$%l6(O)*r+S$d)Xw-? zqWXo$n(}k$X8rn^mQxOT)sQ^#JLR$0RM#_I_3pJY()mQa2cR~eCjqRc{yd;@x4H$E7T^?5 z1d!pQ{Q-d?GS+0OTcOLgL_ZWh3xT3vC`DD| zA_IR3&xvm)dGM`D{-S`UKANTJY?=e=AkpAP=*O2!dJZ^wZ1Ts!t@TG~Bhw)| z`xtEw@0z=1oVGS(Xh?W@3qj_;ZvEfE5qzKO8ttSqvhya z9u9Zb&m9=2K?r_`YJ*yqrNLtq6=APOdpO*Ag60IDb$*IGLC3jcc>4|NAVBjlTeX%zmeBbtQHltIgLjS6Y3(T#U#mO*Qo{gY6vL%7it9u}Tf&C?K^ zq}Jm6OY!AxTB#0Tn|hcVn1Hrorx@n%gINz@rO#r+FJQITfh-kySpNQqN0M;PjNiq$ ziTSSwpC{+z4RccyG}@J=n;p;4Urc|{ogt^A#Z^^FPn#ikjY}P-fZyqNXXyjs7T1H5 zZ#DYejvCnTBV&`V!TJaN&J$$%omu*LW0vj*Xnxw^KXj?4TpBaPhVuS7$!<k-Z%!UANxWI#O zf$!tHM}DKp-SZ)wTBNlJ@FVa)`ba@D*y$arG3Xzd7yTX(PIh-|_d|@8S}f;WzsY|^r7s?%7vS=V`hCZXm-M~?ztNuiHk~nvRN&MLpg*^UAzK_QEb5fVliPm;YdMRfI*3j4JuiH& z9~15Y#npy)YY%XJ6RgNa>SRNG!x45@vpK=DpX18TBXkySHP7oQ?S%(qc>&TxHT$!? z1ZMms5(&-ryK3Cd>$1EI7RK0${FvA83SoSfZD?KQ0y=fbqU>hQjz7Zk!5k?#{ zs(Fi!L%zl%+z~RyXr3Q^IyUiJ=zCYlXz;uJhAh`0s~zDTg$2d`WJS2PENL9!o^Z&_ zFIgc=c8yWB-^{Ogv#i)v?>8Dt*1LM@dbzKz!qP{`j~Rb{cd+3p9wyo<8rCVV15uSn z^g#`MP`*LWPZg$OINXWbb|0+{>dZLH&WuoSKmw{QMKx!T01<)n^Nt?pPVSoEo9FQ@ z!_|E2osqRp4!QmA=czl_JKy=SPtxXNe0w+?g0bI+v+&(n{(v~ZN5Y-Y(-ObCaf0u~ zpDKFc&Iy0Me~i3a{qD{&iU&bpEq7hdBJhwYJNzc_|`0cwwlL8Z^o2!)CY4y6lVENocC|^c`@;Ci>w6(V+IDDf~xuN z>c{zs<209&v|s%vKT0>z-I{)g9uUeuOFuPDoB{aQ3r8V8F-lyq3>S3XFXyK=+W|A(Fa!Dyp=9Do#VA9dtaOI4{IzU}Yo>PZ0X0k80z?{+r z^4WjT=8)k#HLlWYEe0p=(+!;?b*Cg=-D&Xc1|M)5{NBRWl9!M&dalQeBxZjW3W- z!3loy@o-mt_y{e^hl-1E2H!aR3_1GcgdVngb`5z@&pa5Ai z!T%I;%ZK68FfwF1{O)c5&+ck|$!`Ke0__4yXvB}iP|e@4?|6?bdXFt2pwXRcT^w>c zTZ|fG$;0&JhWfCMwm>quXxmU4Z2y0y7;FJ!b4S;q>06>5ow>e~*UOUUxK7|N|C^4+ zl?^fn#|k;e1TABOAM`wqZRLP|=aL#j4rI!Q2U-ky-Ey>Dp$doJ#L+z6F25JYd(~fo z<6(n`XwH1sF2ve-{G;ev*8u}C?;^kRIFx*V-cLV=nmB!Gbib64S}2N5MwjJ!cILkTG}iB zCxZ+L6e(q<Yn$o!A_T~F{^esujh%fA)GPpFOHj(`kfH18Ukb8V8xDD-qHggz zS}JNPYP=88S$?CY0!6h?OIYFe_e5(}bje~pY zbDrq;?5wa^J5NJcUaE>zKMisua~7a^vpSg?Ck}osQkz)vCw28@JcpJ+6PQ&Z=vn*{te2L}}4O zmuLZoMb`inuzNRD;5VRzV`>PD<0{$+b!hjj^}DOpo{;I3xDfI<5bgXX6sY2`@ArW{ zEAih#S?LCRncp~}GFi1BCq~O`6xw)#0tH`jLTNWn^Oax&u7|q0M@Kh)Lp%YfOrTH@ zH=-0cAv>tQ&I_s#@6q%Yx=qtN=);;Gq>pQQKONTebM&aD zkI@lLpF>$H^aXlR)34LFG<}tRrs?bSmZra^KMK`RMX1REO;_;wn)dM3`~yI=YdEE8 zhWBfF6W^}s9e|)h5A#EseukgWG|Mk(`VxPWe~iTRGJk(tw|~NK@|$SC$-k4fx=Vf7 zVe+`9PBo%Fs=lDxLZ$tXW(Qe7IY9z-+@{Uz9z~jXM z=M+KeiUR>b-1cU9>1ymZho0Q)sNJohZk!g;U0r8b6U`P5y@e{BK%{pFND~ApQk33% zl@1bmP)a073rGt{6$ni!(tA@xLBhh?*alh;&-o?FVC}oX5O50W^;CTX0qpf zIi+CSj~c?4dvyro#Q26`fz7C%3m8#0=W1M!X`xUNoq7o*PXfldZ z`fWGYUOj$ECEv)pSl7KVWPHvOaOSXm}4h*+blg?`Gv1phV%dU2!&!EF>DG)B%Qf+Q-d96H)+)wSM zu>NfB`(?=%Y3*~{=wDf_RxGeEZX`0;oukg}bk~>1)HxDkEV+!BwduU*^tQ0P#-L)a)D61es zlw>0aZkTY%Q64dOw|C&z%3?{3T}7=$1CN3Lf+sH~mxrn~%hJiuZ_LzjG)irS+mBU& z?(A`hfv-Vx0*+Jv!{vw8X~)NBC-|O-2N6d--_Yl-P19>J3RA8o*-%3P?Qq@e#}FMH z8G{Fyl0-dE_Lb`1ryG9ZY$4))7}{e@#G(fr|B*ZXx= zc0^ztVBaj62JTi{Y&T$2_*M^LaK0jO?@WJ_{LPwe{at0VPur#P`o<^Wwi^QUxScjW z5i`T0ZLKMpz$(3_bY=WC7mkEpwrB(5QXcz6D-9;#j`|}}WG%-^rm-^{vgm6p8l~%y zP}r_KMP_FpAM*P1$(?f>q@4ru6S+;?`|p%hw|~EL(H@UQq=P8}tNlY*zWV$&oXL>- zoJ@yHOHsWkS+DDTF1@1AD?wy!U41gKggmE-M%(Cb{h+kI*c(ob0jsXy6ZRira)))= z=#;wkFlhTJ(=XC?`Xh$irpM|vWA=`R4bbIG=+@lD`I0e!4zn)kJUvQ%(H=u17W~}2 zYrvuWdt?=}3Rf`KeY$`!s9yA{h3iQ3p=)1^K=+ox-a3X)22czVoqJ$FDI zWsj#sHfbxV4Eu-$<6PiDxUD0t*fNz;0fTX_s4vs3mHG3XofgVFgZXT+i@>m_6AV*g z#iGo8`h&d`Ukz2=^;IHJJBMtP`)KQ~yFds~ELSCmSW9=ZH^L!`98nEplX_}Pqa@V{ z8J%8eUHZw!ZBcGW)5--etz(7|23vL!_KW(a*BjYO&3L*Z$%E$PHJ&jzNEKz)ce4km z)3S8XFL~@szj#O^jYKpey(SId{9LsNdnh8Vs=vXnm$`Es{eNC@YwN*n5X& zZ7e04a*+B=CG#5k0+-HHBo+qg8HCXrZ?l9cx>|lSXG0tfU4y@;E{evF0lqs^7~|T| z>F;f1JBvC_sElUpEtk)8txjLd;BPflGEkG> z>AYF^?mML7D|=AiHgcJ+^^6vOaZmLyp$oBZ#Zj*5k>X&N)E9)?uoI2@%-q1glhD4T zX^PnBLyf@ePbTnR5K2{VAA(Xoxh^MawPxEiX|`>6QFF{dp?|xL)Pi{}du$S`^0ht< zu~`YxNYxeoX%zeZLFj(LvsA|h-^~w;ygVR)!sisE8+6kpX&+vMC2;x-Qqsy>=?k&d z6tF%bt>GCb^^%cZYp!RSDf8s#>yDm(x##Vh&zaH8&k8R)>m4V3KV)E=dv8v?Tq1_? z-WkD#pylAg11Zri$=&$cRH;0@!>G=!R64$o+b%#y=Nls{OSpn?J3r-wv4i@U0bJ5fRrnvd@0XHv{B-y!7=d9n+{$drgA4>-ZLe+a4Ul3eQvvLhH` zoGn|}u#AN-LpL604aj!407-h5bHh*RNA^8aSYMlTYRtKlHn5CeXbky48zTyT`5Kcp z<@|tiM6_^y$l%V65H`*kg{$8RI}|&`MRPd7t75!E=v3sqDxkpxMx-93v+u|xtaMN~ zwIl73akh8D0;%%@EnA^8SYA)L%&)BP;Qk;Bt0w zZbyf*Y$8Z%0-@UTXy7lkMC>y-J(MipBCv+~5(HAcNtg>c8#ZXgg?6})L(_hCfakY% zYIc(S6W4o#v-h%mvVAMlB0PgGWk_5UIV`C z!yi_Oh6^ikVR)Pb(}JrwXmU&Vw-P3N3@fBT_t|h%yt?E34*Ub8xvkr>B<1r+NN0lp zRooNbo|Gr9O?n}Ey05u9==B7AtxiN6Cc8T(Y8y&XQ*jT{f~~cT z)vxy_-2A3$QPVgx&N!ol4kYRm;gKCVD40A5laBBWHgh1+c9VqV=#RUH#B6pYlwQDG zb^AASUIq5HVZoI`j9f{7K3X#E1&eUVWiAf#+SoqG;9`;S{@D*A>OP{M5bE5eZ0KV- zdes-ue;w))GtXJ;n+`rqFLyD+W9Jd-2h3{?zOe2L2zb;2sZ7!58q zAc_VPo?mBW-N~ndCnN-@i3PzNnJZAFiu@bTeKuhO$D2*dS%J5Y*Hgh*jU-?~_$}D? zzK#uTmF|P}0^NS))BMRGG0IP7g-Fv;<`F(_b)){=SqWr>mAKSRv@vRuh+W3?RZ7Xw zd8<_!W3+Q&g1~A@0q=w9#DVzbNAIKS8idOR#*xZ=T8#cFM#U4eGvhN;bivYutQ7am z#bw;q#bYAmN2^JS5wDh!Dy|>L;QTb5TC(4t25K=(X5617{)NpSzs7xFpeszLciJ7# zWU8t_qG{+Jqt-1}g6($Hl$LRyR-d4J6-FpvednQ8YM|Q4y}RjA&&mw@Gx0*pgC&=1 zrn*MmB@MiXv})X?k4Sudh3dw$RL3c?tewUwX?m+faq%8l9K#>!SMoji>TlyQW1r_0F5`{~3qdQ_%~ll!o@l+pGZGKwQ6t8!RxWjY zsn!GWTOp`-bPe-t)ehAF~(IPb#6GMLa_Z&F)?_$G9 zrl;(Co@V4rR#nTsOPJ&LEGh_mp!5aP&Dqe*A|UK@#V=E01OAB64K z=^V<jbONEho~mWQAXreg(+4zBkxPd zErWKfrKa?49X%bdX(dg#ci8loWCv{)QS}Hz9|R9k6e|s<36-wta78OAZ>7sxfmIp9 z>E7;iWClxNm>HpAmOZIZKUoTMzD*@%HWi_fZ_7u7pGT66{SIz6a6v5FE^#C$eS~?j zV~u15NK^yP>ZnQD_8B(W(|~O|++E>UCnPnag%Z{m($R~g>R7^kuIcUXQ(2KMC@4)+U;7$x0} z!z2!+!v_e#uLHKWOEqVuH$N~%FN$vCRv|8K=WT;s%>g2C+W4;f(I$?xUv3#WsW?_~ z-F)gdLa2Rz5h#&E*Fv>7k+gcm{q|fVh<3^`Ax$BEOeQR_e10TAV~ysVVV`_p{ygO3 z7QSfuxx5{1i_`XCw~1uDX1b>heh&F*x1+*f{#mFA*9~du3?M;~mr0<&F>BmCoSrHd z&}S81x%TUF@bcj3uZPti$415Fq3lFuA zkp|hH+f|m<)DtV*pnAuLdP`GuB9I_T^W_~a6&dhPnq&=?wS%XHXFL0#+2>s_p-7^w zCIPIFo3dYHUes-I-|V6PwY@vQ@SUeV`jb?5OkFo~Alx&R-qSir#_}N@i+?w(gwM^m zd0p78SX~&XlL%IZ{Nzo&VfTia;GLE6YapG=2EeRb)W@6}m%2VET)B8#uEX#bb?=XR z==rQIJ_)rRLyBr;*SVID`#TTs**%8aF^6cBzE;h-Gtcs9vhy1wBIg%&0J3KfXq@9v z-S);eFdW`<$T+g0xp@Q{*}&G{u*9frm_#3?Bf#>V+Lgq&*;%W78%)`eQ^^2#q`pw(%CX}=NBaLFo!W-^&Vz$^eq|<} z0Y%!)7xVClhCXs5ksejpyj93}R9)<;O1tWpA$xv&hSRob{mdY6K;dKJBdYlT>vg3k ziyCfRr!=)s&Qht2Dby^!_+nvs-OYwzF@blgZPJ$4;|uHdq93s|(r}U6kp?{w%?gps zGL-Ik&GXT@UT2iscn8b}q$bTPFn#Dz||GY{4VA>nu(ci z-MI2)=rs(G9hH8K=@Lkc%j02*VPhR6003@X0kY9v0fb12} zE(%(13R)W@iIT#2LtwmJH6qMTsahT+K?D)qF1c>^IrmA@iShI{!$nh z18T$se)|g0M(`2{sgeZ!PaG2S8*p6#$w4q6HdJ91nCY)%+P^_BP;*s^%zwqd004n2 zMz*9eieNGlzvE&0t9bM85vHJWsB&i6SbunhB$gBXVizFRA{& zlZOozTB8Z7dh}n8cK|^6ilL%=D9Q#J)J2Ur=#s-4lN)##4W6ssVl*y6Gqpr0k6Jn0 z-;}hkQOlcQUL-0Sg&}f*Z&7Zh|;M8gZ^^u004qlfLnH#z}&hU zp#SHjxq2~x9R2~}qloK8@GfhPQ1bOO*zS)1*Fk~`t_S1&Db_(_IxQYzI%!e;_1sLq zD@)8U=d>SlT=gG@qG-4a`n#tI09?NkpF$AE2nh8ci2O2{{)O{+%c3DAq_w{4jw#~-2ZQC|aqZ2e{W214>*fvgV+eVWndHTEm&ztLWy`EXK*WTYX z6Zr^{^Z-%sfC6Bio<;~`Lxb84AiGJ#T?IY=G^kLAN)rdvZM4~wjUp-s zbYIb1wP`4NKg(Yp#s9WPh^M*7f4~@6vX+t0q416$qBMuY;_J-$LA$t|r~;+cy6EIW z&rk@Nm9$&0j;xO=6G>0cJ}QPHA{qQm()l>wurnu(#Dproz^G>jY8_9%wWD@#xut_1 zafbIDLRA44&VF`MKMf2FtQ{;xOqD1_1XUJbU?D{3d)G$WtmIKKtSgmRRGz1Q+jFfv z-`ts9FBe0G^H(kp3iT5Vkje`#WwYD={&O@ixElc0M}NuTyXJU&huMqk9-({(p8-$m znz?33sCGUT@l8XvLnf?9lp~`E?g-SHku6@#WxSAeOOp4FoT^pY%i%t`n3(9 zO=G93p2J;~7Z)T#m9hBTgdQQ&wM@(KtwQEFwGoa}XHD&wB_LMfbcoR4yt-X4Pm|Kw z7L=d5c;N?`yS|bwQ*aEF&ULw$w!F@|aN3&KmrpXnz{>pYJuP}%FU1}85jq|41|x>U zYbtwWx$MW8^T%Uu4;M0j2Ngiu=0D=^n4Ub*&QUpvJ`nzIg@_}4voOKIz;wX>TcHj# zISeZ;QF-606r62*TzPGwtw+bTLUGL2BX7Bx;6-D#aaFb z?7UY@ivvPszyQW3uIK7*@SH82#i+|#kM{O{IxB+yZ{sGv-*gi3ei~D0Q+5OXr752^ z=~ilHI=;dU>V;g~0^CAX-B%D6vRYf|Hc>M37)TbiIx80>Jj{iJ>5I5V8^`f_fTVa` zCm<(%6z8MUgHf%)=C6yYW+yW!1Pr7YLh?Z1r#zG`^!$9jYp)tM_?FI*akmvBt!}^; z)i1@PRI9oD9+ku<`oO^ybX18JWHn^Lvqkvi>Hz1ecJeS7iTxf=%iHfz9!@dXXRwHW zynwnay|qQSnOInsa(A1W2r%k+07-2r++;b=eF%x!U#A_6O3;)@cg@q~s9J{N{Lw$! zswuFgbMdt1JT`fy5AwdTKo1;nE6{=PIxK%e*N(EKPPQUQ2>(1mb zjwN+IJ~QrXp&4DY$G|I=CCP*UJ&Nx8;C(ha(H^iK(R|*gs)##(?Xonu0TwLrrgCqP zwXE3)2|N=tan3l|J#`@8>L$C|akdi*dHxbGGI!E+RfHmC7J_gSG$hN^hs0ZpjHGz3 zGC{xT%Do@w^ouU3F{0)^ecjC;hQZ%@&0HVAB;N5p!x`V(-b8x6quhWKhvYFxH|!i@ zbHqT>pT?iDdjq&rPJz5{tWQZ5{A?-?IK(n|GLfOm5wU$Pu|T zkx#%@^z+`ltaG8UUSMO&SDczq+yKmU7MrQi87Paqrqoqb(CB8FSWQ&HS=m^bg=ckBVRCqBG5=B;w zhlQX-OT6Eo;&`#=e4#9x*Ts4kvH)T45=>#UpR>r3C$0GV?SH)q_~-e#V$0cMG!ZC& zn*MxzH8V;iz?L30{W+uc@(Rvl#FBliT|u;FIC;Ys`+iTlIRm!&ee3~&Rhx?9vI>#d z&B}I>v7Pm!^RZ@uzBuGiba z)Rsj~93eXP+8hi54)J$W1Eb@k;#zcY&#CJ=A@uAS;*7Z{fB45%L#^?T*v`!5hu|TL zvMwqo>Ai-I&l-8=9aGgbIO|#BWca@-uD}lM=Y#$<{v0^k-bS9lF8A6Ou(prDRAlL* z&)HvhL-oxCL$4RDN0BR2rCfx+W(gb)TNwplTr>%Ydsk-nNZ?ES<@uk|;;QMt~er zTb$v5Zj-f^jNe!ZDXQ@Oy_YMO+VogX%XIf!C@+U%SL+?cH{v@t>6(wJJ=qn*-&mHr zos?VvZk<+o-tCN3o}8YYT3d34mZ}J5X7}MeMcEwzaeFhWyn!MEJQ+TF_k)aTlAwh! z$|z~_g9;@0Q9OU<6ufokzHBq6irYkyeZ8>kw97_^3v22fXMB{>MfssxB2eC9-6Mi_ z5}JiP0CqntLu-cT&t7$0_s* zv%~S&tfas@3DQGn8I0JH#8p6zI9AVES5(W9FdTW|1<=isp<)D+X>YPs*SsH>**Rr~ zY?+xlsQryXB>7D>XlZNy@_M6o%r>ZYCGr>i-cd&b%g)kYR*WHx_cUCbG91yi)iV_E zFg+m~a!&hUwPV*gUje_+{iKUogY`M&YA@2C33N^sA~v+<-H=!6N`qP|;Jp(iy0e5j zKnjC(WG_ffVF$`pY8%&SL{dgaj+xHBrH%^L`jXdjcv8twwDqtKocIec4K(4J0m=9E z^GgKT7O%xuv)hi$Tqg0JbR%1T2bciRre~49!e2^-jsjPcVB5arzP(s(N`(|Uq+kE@ zM$|4)MOyd=A*&ycIBQ|E5`zsOR`@&#W6ShXGJl2R%mnu5zi+o_F%9WACYw zjkoLH%*auS?#{CfITcc65T>tSIj_-UOHogUbBRKLMAbCo=y#ewwb> zN>-3yU?5m9FjWOeC@e6z6nRz56eI#gfSwQ562TVjnW~%yMJ^ZyG`#_v9BOooC|p>0 zRLmKbZQ?3kg#3zJxvZC#fmSXWEY^wMjhAHirraLN`Th3N8Iu+BP0)S6&j;k&E~+bY zWW>mgrdH%PVr0M5T8)!HoBWR}7v85ov-yDtyD))cuJ=RH0{o;=@fDeF&h~S)09$LW ztSIYhLt`FIoz^MKnR(vnjm_Q4K|41^ujEYjJbWJnKH7EeN&*xW$%i#dMcr&lVTEoi6G(RUteHUl7A8e(-N+SD;gR?$y|OrS)xm zY@yGiZhMZsJ^j`ptmekv^N8^RAd3t3J~Us1^R$vB1I@fGcy1c;(qM$lVEeJ)-=rpJDd!1<DrEO$F0M%lD=jQ)DEI>PoQmo<=22hIGwyKD#Iv`e#f11Q**A0B zq0Y9O>7Z%QniGRyhEI8FWAa4FIi24|7Kpo+zBs)GDk6jH6mA$$r&n4^BJ}W=MP^;0 zDah<{c$z9FYgQxMQkI2EZRz?u0bd~FGh);Rp1uD1MN(EITZVRTB#0Fa2JhUcYO_?k zS=)ttW5X@7o*2m@!L+->#w_Dk@^Uj>)-dsKli{$|XnRqX$2|IMC*8DUO;yXrY(gg% zI*gppGkkRSWnnd-jc|~H@!D*)!xrc9EM5ZZBbBzP;De+v4=|T|vNA0yDT91KCmm&< z-q~8I%;ypgwMdliusOc!56U6Lop->}Q`6K$8>em-fge2`IBQMMbApj7mC9V)VlNFf z&d{YujwSJ%D~Vd>y*z(bTau(!Ew?W?Cx-jm8Ic7?z$R zByV+M!Tr@yoZuNY@D+&F@Quv5jc~Kfc}D%qDVNvUE>VP%n(@hyj_^vz&6j;r4Igvb zS+vs||1!3oKYv_3`?marcHv1H?te;|u&0i``6&)?4&Q)XLcgQ;fnTz-?oyc!xQS9- z$T(g29WU*Pzd%P(Yknh#+wfB2uo^Jme5I%E3X=T#Dr+@r>Wt6JHb2|j!>{IxY(Cq@ z=Nc_@lL{obz+6~O%o)*|F0?cJAmy<1&Hv1pZKdJcg(E!}D2e0!_?v%v z8LA2!wK@i3l&kw;Ny@WG(YFro93EHUQ_V?-v3VFqfpxFWOqJysAyHk##yfvs>u=szdUcGzBCX*4a z9aQ0$jdSQr8XgI&D-lB{hYns>OA=&S_~|eyVLT;^)9d3>JWYScEi8W*k&vLEmy9XJ zEJ>*|HDOAMrzP;P!e%xZhLnhV(K?m8)rq#PVI4qkyQ8F5EYMTyu4fyey*bKN{WF^ z|D`Kf?|4R;{4p>nf{cBK%fT=>9h{UCc7cF>_j0TXZ=*aNoW``DZ~m7R;w!nYt_N*t z%l4-fuKmRvlbK9-8kR}y&E@W_TT zhP`j|pHQVo+F561`*f;7%4+rb#q~Y1OMkvCo!B)BN<95R);ej$`g=p?1(3uvh}3d~ zG-_!LXlo`}SvUD-b+B-A5@Ze_7%(s!l>gs(Ql~hRvjX_#X*aO20fpF2@58a*71;>L z-|R!(U?^{#ubed3FXp|eMr8ND4iM!$X?ku^Zxc9#nRCGvR`BA-hkJiX5+j+#OzYYol?JXSAaC>#_Yz- z9g&KEnvQk;4KhGGizFQXMjLl5&@*{~lCkB__(+e+Sjr4D?2D0V?~%qdlX7h$*-u5z zf_iocft;bL>4u(x@17@U-s=_n!45u~UiDtCL*FKSv?nJX5D)iD@EZRM{lnD|nTM@r zpC90C-xgY>mh1&|Q>hJbf8D4WwR-Y2+jmlDe>NhQK=xrH9PUzxM865H2&7>KCUJLQ zBVo=B`0qF&esQ}9KtS_*M_khs<73qig1#4wk0(S<8Jz}witwCig3XW1# zb9`r)lm~rMB>99UE@Xe;nKc9!QCAj7mnc@eBWkKVDz()QDzzIbf#XKS=Csu@EV3*S z^lg-~?bEs8EzC_u<<@-XhSAyafT*e^_ksPT@IAMwb90&+6%6gN*((L^1*-nMM3jbP zvfLbHW#y{;KvSfUIC&-e{WLQKU}g+c`9$(XnXFw|l76i&PU};(*$-|OVsaUsDr4JB zRKq;EZ-&UHWzPzRD*s)BIyH$Fd+82`@+?Yw1p!=hcKbh5$DhTDAOnqm{6|h zoS{Kj%XN7l_$U;Hq32!{Z8;UWVd;ZiWEw?=fteL2~)irONhj?I_(B8 z*k#8~ym(8SsK)ULKUTL2jUprH!;CdaxYVaX*gv%PV*aw`ZcUr)GFsE0JbYlh-~$#S z?#Zd)yr*VdAlPX{$(8(-HQiU=BSfe39oc0>^jSMZv05XHHjZipuuz54E5foxpCCn- z4bP;GITV}4hjIVN%5ULTEr?f3puHe-sYDsc)OtsDT79W7V)-E2v}H{Lij zlEF!*diX7*Ak>#myW00QeJi(i&e40^S_%yskWAYj_K*8e&(ub34-r{&us&>eO}&p^ z5P~`OQG}I`j{e>j@`>;XTR+*@B)p?}DPl{P*Tp~6#``1NCKf><=HG=OH`ytELnAjB z?vfBLUq60OfDMRgqvnibCglkTj`UZ$$rh1Mqu9k!60d}MT;K98pCo1I!_$mZvlkRh zM!>I@p>c(v42*~`FY&;OC1|j^g!n0BB#c3T-6NjDY>z1BP zFB~dpVr*^-KthO1`$1pPCy70wYNh&v%Pmf*U!c)i^6AYYAU#sSf>xmR6L|YHsU%N(2`}On_p?v zfJ*;J-51nSNYw+@_VgWc8sRaiG99r|u45@mwDc299k!$V_RE`eJ*QVC3LBvPUy(8S zxOemcPr0V^1_&-nJI0}YyZfb2Sq5V=RWHD_an1Z&!6a7cFa9Tl3Adfyf5fdc?+Am{ zlqTSL%7^7)+UNK^fprILQzC5dV|@I_vO)9Y6XhPkc}wNXVn<~_yvv$klc0`=padFA z1hX$0-Ren5*-Jz)7E+Gr*0q}en%5E!cjS8(_LDEbH0qt%BE{XxL4SvE^&9x8ILu<& zcWsNRhW64Pg)nx@^=;Af{b(yhnCTSjh1AV@DUH^eWH1n9BZM*hsRH0$!bhcC_ z>|zT*T}agxeGN&yt%NKt0@|>zD0FI-k*!3+#jw9#^Vf_8+Cmc-%a>G;e<354ESft$ zOkH*TY`tKl{!?=(w&CP1LoH|OXPgL$ZVS030?(I*a7TX)lD1p65OQ~ zu>ityJxOz0u_^pyl8&{hXB~rtc?QgE;*Sx)v*WI3MBsSmobT_eMB@#O>xAdA6jOTt zc=mf)x%>O1c*<~enTSi6tBI%u2=`{?6QcGw&ch2Ex?q~9LgjCAU%Wwoq9#jZWkLlD zv;?GjR0;M+V5} z)V#_0fJIpF126`-PTwW9bJ4u(9Ph>O5 zNzR9yT^bXBL5ulcF5>1LhX5a~#J}Jd^st`!=QS_@_IuE(`R)DRz^%Y0qH7dYKkZ5P z0PSISFE*@X8SyIn0wJo+0!b1IqwR-R2_Lr{PPDS-3U{c%%p=Xqh)19P>^Jv&@rG`!&j~|1j3T*( zg{9w;%ouEwg)sme&E!k-aESl8+7f_)uCey@@$mKF5co>JW#-kA%@#ARLaYhSkJX|y_$ z=;j%?3reUOA{e-usJdsoQwQmsz6TH9EtL`joZpKomZy^kFQ^$0b-9Mbhkq99>+ucV za5-i}z>2>O$%n}M^$7CR1z$m0$U6BV|N3!~&Wwj6WM^M?2xR8kYH8A%b!&6bWY+20 zpT~87tLeP)C*6ATuejxELBAEHLbvf<#NGy9nM}Nu$S5?tk_W*USx33qaUlJVeXjr< zud~*r$(UD47QY9FSgEpd$_Ppmiir8=>(*imTA{Un;df#hDg_3o9G=`~Bh0tuiDTeH z(<41nhEzj9uZX)yijlraxWtk>#q2`A=#;Fvh7(HOIw1tGuuqsO1tMq*QKY zc>F4H{vk^Db4W6Ve>;8vcPq~eZz2sqsN|Jxj=|s*-9qt4&mrIJu*r+E-qA@){m-R8}xkf7<9H=Qg6?k5FM4C-os#UVf(HT_`&0pX_{c>;w*V45@Hpedbs&E8^o z;Gjc`C{Q}7M1YVoVJ5Uo(N{UCRD&WLjDCcy#_BCgEQf^5VAcN%Q-X68kA=j^qRBJ% zjugupWP3$TSj-bf*tQ1oZ+2@Gy1NmT1R!G87?!0eU@5zvRHWq%Y_)jK9z7oN`IDbR z$6~j%WZ?y^4Y?LZN0KV5`mYSV&b3!_VWT99$U3gUVIe}3KcoInbVgX3<~IKsVHLr>)=07u}_IQ?U%#M+|_&V&_A9>k&S9(H#I7}a zT6Pb;rcS>%0JyxlI^@b?FjgiLiC}RS9bGu72ONunt?lSYYLrr&Dj#XPK-eHE!HjH| z$l=gA>AJqPldwg)Bp76=+Si+9j_qu5#)0pQIic{8FYcniZ!^>N(3#R($l~;P;zZg| z`N!_^tZ%9LYk~EMonfk=0Z?OzgqQg9o~MF(iJ{Y2_c8 zn&+>TmL2jUB858JeoVr%x}K-5$M@H<;G}j=_+g-BR%wnYCw&nqn~aH&=njm&Gk+-tSnFv4cIW{Dfd0>XvM&f<59k264M9hTwqL;357c}L zf_NdBeR}?jT^CTqHUH12xjCA~kFBTs)SJitmYZ8Ao0a}z$WNP9(z})0ZHuTVk-#S4 z=P2(TCx7XkCGwe!?dSSv9uwf>2_o|^BGOSg#i#h|pj4oygg1vEO+L3G)BeOwHYH7~ z_#t7`g#%hdU~tm~-w=iTtm&d@g6BsJA5exDBv&PX1lZM0A5ivQxg9=CDFt)@N`_kY zz=dYT|KKXcG8e8WWW=~4*}9mB3$)PNs=Gq!iZcdo1NocCULs|^eF+@|OTNe&f>Z`R zcIhu4R)fAE|5q^eSxA@>Qwq3s06K(LXu-x9x^N-sO{%t1^G)|jsOFP0^h#BMk11#j znrzqx8J7yWtCzqn{4QVhGTWmR!iBP~dk!PwV3Y%|jwc#TYnMTA}oL));hu`j>1QHJ0 zhd3)&?a2Yn8xXA#+2bA~I6B7d&G$|lskBp$&fC>@n=)h**@i&xE|lue#CK+zTsrP2 z0}1SNS!RYp-^5DI07;@le)rG=e9I=tzt`1&r(d*@>jotB_v*1Z3KufIAZgjO8S=qn zGU6bskK}Q1#qGmwG&oX=LY+)yw~U8Y(&wxT9St_oG2gBa?wLy!Bf3}Nw?eCV&6!eO z&+|_F5j1iI>H?nVmtZ?1Rk=B3V?3fOO1{ zZtl}Mxn~59P)B>micpv$E=C8dQ=V4NP2y3lbCa_QGB1y~XE_s9p38y(9r$pL)hI90 z#-dLeJ-2^DgRf?B2@hjs?GXUWs6kUcO%DUf=G_TLGo>6!Na|uEK}pStq={1BScaEz z42QCe4l`>6q{VlV_Mn7lhM3P?{M|_(PUF$MKoap=cYy7h5L%c5q9K?9wmajdCzEY4 zc=D;&5p~=$nVpmQ9N+wnyuJn1h1edi-0bYx#w?Z=K;?c?mN(Z}xbGKbQ+W#PX;fgs z3}p`X8{d2<<6|CW4z7RHHys@DgBZC8^pJlqz8($ zM4B}!08665%*rAd>iF6%df5UeY}=U<(LTzcwuLN)juT9Yo*X9Of1YJ}v z3uXbQ!6Cz$LWWdHn-ZwedfNvcQjp8H<}>tL1;?en4_UwSpvA@x6n=>O$MCDeYO%c&-8b!s|0Z)TXVXQhlG}ez*=`^yH1)IW3LXF;J+mdEviVH5Iiw=SCLUdIJB%Rd{ zNk89C@zT5ab;5c#!Vc&D*Z;;plrvhflsG|WKu1Jlph5&ST{xGMBwgx#A)08hofyQT zY4fJDb4qFUmX*(fIyzSGZHi8$@ZCt&z9NISbfD)V?oQw${wIPBS6-)`0=?Hr`(|74hvLoBD;&>gxJK;D@bsK7gL4P=Y~w#lqO3E_jF9{e z7{C!kD`9#lm}ElqV5-m#_1&Rsg-nVGD6K_!>0pX>E1-LR!Ffrn*%3Gq(QkIbh(#13 z)5Li6e(}6Hi#E~d$20e9Jfe?coz*~=8jaeO)=%C$YNt3YgdNMP~(D3 zP`=7>_4oC*3^BmGTEDQrrCd0mF$ur*JX{U4k-*=r0Q-Gzw4N!+iOPcxNJ3ObqdP>c z7Xp1Ix!3sHUH;@>)Vh^0BNe1rM|pUnP%Q^s(At z!D8#4mpzwV)Eh&b&kzN&1aeE)c>NQ)_i~F=^v0jW$o(N~4PW zT{mFdNe=sHt}D_SfkmE05D$4MS0;0m8(E)j+UbVuwH5Xww^JcqXInWK;$cnXa~ba3 zMtK7zJd6Ed*flNv&&e0^a-q$4{3s#`JrxMCbx3vyQpD&O5Voytsh9JfjZ|LuN`O8f zf}~Y?!6%|2Dc=_WJ93oc1*y)EGRg*X%R=CRjxVV zwxOh7X~KWNJ?2`nU|(b39f`HxkVz!t{Vef;mt%SLuFV!KZ-NdZ5bV zY=y**`7|Dn=;HP<-ODim{D1v6qJilJ5;PdtH@N?TR%&rW0ArDH*A)CWCJ#|hXbbVE zJker2!I#HHl(t?X=Yq0+>w^0xkaRl#?1&u+=0Q4=oXndwj$dsDgnycL12a0gf8{WF=BA)fcK4a$; zwg!%GSm)JL*>af5?bum{)1K+!b+c)|a`Jc}-{q4$WH?gb^-!Hc(m44t3X`S+adVRx zggl`;SiQ<7bs%b<;!=ch8WzJM^ZMA*aM6i>ZqB{S12pVdph#*-zftg`W&>l9uAFk% z{)ns&3Wda1P6}(+H^qVjA*#L<>x~%Bt^9SdZAovXpWcHmzD{cM*t-GC^Z@?o5n>N;4>?@TO zq?XJ>@&j<2grB<>dpxkq_jnfdOM)A3|BXhv5aoXP{{SZAe*jYi6*Wack{z(|??xux z7ja6PTvQemb{%sW&Hyk`-z6#oht56=Ok0$&X4uLDqAGiSudjSx2GR;KWKI~PC#YaDyYHmn;aK2_vZxZWyh}Au2Kp8RT zYfL}Hjc%cme&amrOlwdTG$Nui?}d?bqMcNfT+r=gv+JwGus%JmfB3g&=bk~8YE=IW+Q5)S$73-Oyc5sBqw7@{N+W{ zdUd$91nZ%S^dh-fCW)@}GYU0s%>L+NrEpm`$Czc)3%Nn30l=*kOr4WU3R;RUA{qXUS{K5;OViP$FgafP4`H_L zUU~t&6v)$ELr3xivisMzbqR&mw0=EZ-n*zm7#shmS z87cXF?8){XLw{7RqjRs1WX7V4U{!Ed5C_>LT??!Al1y^IbH_Oe>UG-?wkLXVq2T{D z4=K8JEavw)`iA2)j5EmFVTyNBy7+f&4~ZL6M2DiBT2V#w^=3JZB6D#zn@mO)3!iNm z*}xQT8la{LFACk7mlcf(ep>%QS0t%-hD~@;M!@&tn|#ej?uwu_$c^8}%L2bL8SZ|^ z=(+ZTkqhb_=3PT52)4^N8(1QG%KB5gh218my-nJGdhGtk2cJBSQq0ery`VW|QnuF^ zYPARZI;s@zfFpikjAK$nmW;+%P2E6gGKbjOZ~$w?zoDvv+xz(*(17&irTX^)|0IOt@|4sC($E7LNg_Cx8KhkRRSpGs^Be;Tv9) zUJmIPq??5@tMV@3rETATEMX~W!X=r9U=!hoz3U9fJEf?viYG9AU9i0Z7_o zP^&2WNRo{*pjppumk=B@ZXRmi@) z(CuTp@Z5c#1ynXA-z^NmwC=%AHm>d)7Wj!AIoCW$%ynSqhsfe%k+vBE7P*ng5g~cN z9-=p56KlCzWWL-|v1F+jzpB_G@jF*ZDKZ&(9nx3}RvtT$qm9 z8~5ze+XwEBgw8y7$M~&YjQw=cSBVz5BY^X~e_gq?=l%Y9GK+3*#KTl-vfsA=!BlAN zopa4j%#b7P_o5htJe^%Y;RIx|`jN6}tJfz85cv5O7xJ4wE@2}BLcoU_Qeq*M$2{!% zv;6v}BYt**{g$5KB6L?ckpg1K)v`vRmZMo`C`=XvrvaagIY8xq!ZqL-I*Lm~J>Yom@DCps^-v6~+98$3FF@S$Y4*j2zmnvEV zkgJ=JksU?x)#Y`|VX&pLpvs@450)D^0ZpY~`In@Z zF;_w`(md>33c@#|e%rI7pI5^vrnS0p*FU$5O_=KCA7};24ErU-GK@&Hr)t?+2ikA}1Pa90K=YB)jWm)Q9iq&GE@~WyWg@e%^@~^K z%Z|fU95GqFakcE+{M>Aw9uhDMkbW@H&;6_e1Yig0s+PcRI%A~(_|1!yYWMk#Z|!W2 zhK^L^sPp6cr{L|CXt>z|Jmp@rs8kkt>v*f*(ovp~(bg)Kb;-XwZf-#vIN*Z-$p>cW ztZ}Pcv%VrZClWPE+#Q&|y}7ED#UG-M0>MO|ap+tnxa{>XPageuIj0uDlwlt(O??|# zY=sMcVB$i7x#TEVqaGdJQsJJ^uOqKhywr6yVAoTnKI6U`5sQx<>VUaw3?pOd(^ToV zT{5sd<1E7z&zV2(mFe0kYPSr@0*`8Bs)n#D ze-)3sOKlt!0O^QunR`}1 z=Z3K=Ns)BhUDrKi?~hd(e#xsh<9?sOvw7e4>CtViwBi{+#%NX_w4T|Gj}T{6+IAk|L;T3D8r)RKxmo zMpVQv3vJu&iLF==H^5GUU=cM^6@`auxa&8xX1O}`EZfcu9g^KAy_>V))-R$Np;h{; zcAg1Q#!U0Z=EHX)aPcwo_w~;i;`bjv&3xN?gfoI_S=}C@U73R=0|iB}Fy(hEGYJ4P2)bBpR`Znc=y z@}j_Nhd+)p!Z)7vKlT-TQoxK9%miTD#bmco;?3tVSt2AG#E(_W8?zv0wqMtD#E;f& z>zxT15Y|CS?ZLE+L#i;?nyr5Qth!cceD%?Vse=GDibibb$b>QHPNMPlt6|+{%I|poU!&q;s1g^K zo87Q}K+Z41Lo$@+0{|Eb50yh_L`1tC#uBXp#sKMX1KY0yB>isbemP)#z1cjX;DQI< zvooQ zj{70m@5xuP9Lj2mWCzsUbqeLqLF8n2%_H9yC4`2QM68Th2k4T%Y|mSm!63V}N)o`h z{R)$IiSl30NeeXt8fDu#a#F*X!>U~&4Bi1ivi;oWj=M)OhVcsHD|q^`LxVX$H^mA( zv3UMJ8P9@KPWNxX81~J@dcv)}L8WBBcxxUaoDFOr#!_nXn}w?C&)k2nSYD2scQT$jQ3NJ=*k9xf6Vf?bxLl9n9c}51dgO##yG=s z?_ko3uJUmeuNrkrkIae<-k%;BLRrnE^BIu(a74nG^er~!pG+4#Ws-@%<`c>?2|2;O2xnK`V zsnyT~XxeyvqD)M&%QKi%@{C&p}^v0_9{jzE9s7BB0l?3L>POhDqP-W0goB1#gax!%9cUGH=Nat z6BcJ2yzuuLE~_0?T`EpWLljrPURoHvalSS5(V3I?j9T$ zRX-Utce_2}Q-UQvsZQ)_Z%xVVPN1IvB&5UQ981j`6K)I1u|~XkUL!Cmb^-=Wa3vY{ zPIA`C5jCON`nBYyHyt0Ta|Qy==Fqd-))ayYHIOWbFxv37gbj#GU4}c2wK3V5Y13kx z-w7dg_bL2ts&uK-XY|_B>mJ}gv3LDVY zLgK{|a!u3P@1oeJHO#r>)3qg&Aem!cR}V@{Lifb}FxEG#nD85)!PzW*Z<(2gOKW*H z)(Q*JnQ!45WchypoD%wHx zdIatxlR5RAO)c|TnvYqK?>_8SOZMv}Yl8@&t`>gWA4Cw>>1-f~CEjc(h(I*IvKeqP7dX5$HWopVnlzP z?PUw-VnN9huvPM5#-S5q4mbY4rsy}ETIx_bR>KC4 z3u>vd0ak)Eo$91|+6g?YnZ+*b%;tX!LA=((J?i21W*sjsdV_c!Uau>Ul~=67Xb^Ao zcE8E<)%%OKLuY@phx|D~9g#a6Ya~rkf_STkf14oJGT-rk8cgrccFeQKJM{8*Qq9#m z8913KSSRj15w~+%I1RA8uACvTuSgrD?W*;kXSnaMSn?8)LN=1KoV*<&9TR`=6SQ7i ztie$ydt9?+nW6Uw@LoJY)g{VK=GK5&7WANGwo8lbW~~9iYK(y=%emfisA8`YH&oI} z$)p?!aSRd;y|_wlYXcuE!hN!IoX7v06-VcW42dx`}nFDk~i zSf+lBWvQCa*{i>zd1Vq`V5?79%1dvY|myg z%n5EcuyT$JGi#J)91{VQaGu;)i9}UYx<}V7Xs-Eml&492R+aZ#EJS~CD-|!YQ?oVo zY#VWdp`x9yl>3u{TYswPQl2j1asXez z7bzVHm1$3RC%%?m-Sj2=zHa(5Rp;JjCCWR3_(N~$k18>vOzvc6ox|uKGvSWtQO~8e z)1!j;6HO=2<4-H)lXidX%~UzSSF0EHR7K#Q3s%gPrn{I-CA7}`g<#nN;(JcR#U=xo6EFl{J+81^~B%OCXwN+!#MBLNccNJ$3k~plhQvZrEktTxj?a+ zfASyo zweP<(&_`VVU^OrF*_jc|{r{l``~v<{u;{^V13#d{@EEn~Z+Wm=3&wxre*(CItFsz1 zGHh#S|6?Yuc+t_c<)P;GLmqZ>ke#jsFeQt4Aerwa3yxFF73@S%e44|i#P5gxX#i;a zN-iBJScPJK`zU`&*)C7_-A5;oS_!bnkUHMBQ1&q7I_^F0up4EWqa3{&0#Yvx0a+rA z%sQ2ZAadNwN0Kw#bea+p@WHZ-^^#APpsY}TTQAp>U_6!0+d)~WJ@ge>rLHIoVZdQLscw$z^?zvLl5H{jV9HxY% zWA|q>{d~a%rG<@;+$!rDV9)BlwZ)hAI_bc0eXVrPX{BD$uQxXcoABltbTV@+J7#zF zS?MDQYqwpnhOIdJmXn6C8?Q^xAOv5ozM*`MCq}c0omPgOSAK0@Hak|#UAwT)IyiVT zXa8)A-j#pXKD%!I>UJxY8la2NIbR_g0RuX4~hax?$`6Jc7*csXD{+Oi4^6Ea3 z;xvN;%zk5J-QtZ|C;6~@;ZCM+{b^;ztoMKX^aGjpOiMC=@}$_)X3&L zGgjI*S;ZZ_pK6Y)BQ`Nk3ENvXR`$rG3OTBE` zmz2kPEpQz9B6ml=j5+$X=3zN&8c%>q_2ciyGwY())G!feI>jl>M!B#(VEt__8Y=J zYNTcy?|ORIB;FfqdYnr5zy+5`9oFMLyspt5n`kB-mm}2E8+7^zjL^R-#ZJdgg+q{~&tWFXH(U{w%ho z5nucmYR+K8B)%4##0#-ihdajc*Cl+T>F-PUR=B2we=gzMThhpXH9DFzwWB-uH9&c{cR=uD0W9+(MNG`LKc^#=1J^WZmwAtIDM$w+Gwt^ z4s|aTBVGIWhf%lOTz&fSC$Y434N-RYSsY${dbwGWVAyC|yev?XCZ61AcAuI0lrk8S zrOHW|9@=^q`$Dq3B;k^*F3Eol&-77RKArhUwCw`cs1cE78l17;$xC@+CFOke`E*YMkCPZY&<=sPRQmS>+PmP=C*}Cfw z0_C**6nvVkWJMc2?t$2Zv>td)wg9pIwvcR{kZlJ-vRwxzqLAF%F(JERQF>zIr1Zr4 z!#)|8-Dhx|(RGUWklgR>@P|1c(0TtQZ0Wd!P3r$)|6zsz{k(r6T9W-8p2PPmrKqnX zBoFYOi&P{x?GF3;&mhuPk_Th`<=dEOI`p=wZ!+hcnfiLiW%!=q#gByKRfkJrJ&9z(#B#b}mkXY#fPF6ba0T4$g0HQB{VsT_0!C(iF8umw*nbv-(VDZ^uQ~mN z*rBM|9yVib{NKv|T9IQcLuRZ)Cx(7%!lq|qI?fw(@TPB?nZ9|!>05r%hPQdsZ$ItU z)l0^8W=;O9u#kS2FP74gdhJ9Fu{1Gm{`=F@I1{{9FV|!ge2! zSdo~Z6nLn?w3LLWX4su>m(K2NW_K~fNa9a2CTKMA2l%6mw?$(VAAFg)U+3I&@65OF zpT7clghhsTXJ^gY8|#R>eGz%qy5)AQ70Xp&97vu9DzZc635shW1y2Medpxn-o^bm~ z7ADqum&-(~Sg~)1Jb$)>0mlfEPK4f7p=c=8&te0k43o$FggcT)zO#39EZo$< zIK%8#eS5Rr*zfE#H@6z~PPf4tc4%C}o(g{ImHz z+#etxlwcyC$4VIH8Ae~wo6_#bV9-Be#7?=PWuky0-3#O`VT3GT?h7UkKVq_6{)E|c z+%Dgx;~drpJbVufVL3WSkoy5pO9u!Z?6ZZ)0RRA*lkq4WlRpm+lg~;BlOSUTlYq7% ze>IN5PQx$^h8+rY?8Gxf96(!_!o>1QMLR#O<=BZIrYF2{DP+;=lv&PzaX^ zA1wc${r~d!&*v8aT*6Sm$8PtKzsV))4b?`@r7q-H>ZbD6VQZT@s~SVh13AJ-yj)?- z^osN*c2$(~0$rrB^kr6|&upJ@ZRjdDe^b5<`MZFSgaLseM6*s(QCS920|^kB1WJH7fWpnpO)@ewH_igY z9j#SsTf01KQ|+ru+q5oKDclLBidsv3O09j?+Qr&^v#nk1qOFpi-@P-*Ofm`h{(5{u za_{eV&iS2v`Q7JEJoW^Db1bEV1J_)$dHIzUe_cv&j~WYA_$q?k74;QCE!r23D9NxE z^G3Ch%Ik48qACfMH+z+YH`uEN_asu$M1`+gi6qqeioPCiRO$1E1&tLUb=mUJ*$f+B zp2hD@MIyYgSB)o_DlaT=IJe>Kb1SZPfCzGjmWl_}wc&_LsMO*;jXiNC6j2-Z#g)E3 ze>L8?&Uo!K-`bRLOoEM2dYQ6UX^bebp2m$`m#M*|1UtbN<$c0w)jRxS`yI)6IM%bW zW*cF~*y@^yl1NC931vNMa#LJOG7;vlSu<%=RM(g^5^69N4=4K@$HT02!ii}zN-&+U zU}{(itnq1l&>Gf>DKX*n2}g|WGI1Bme`L(WEW(`0pd_3|C}FaGEoj(`6_X_zQg6eRB|>?%f4o^&)@*m89$p!BXkc5Jf1HH1 zMnSW2lalOZBfgJdWro;)s`G`&%9IPwtS!OWGS0#|1lPoU2}_x!q-J{Ls9qREtT6U+ ztSG@U_?VjlZYJTptT!0T?V90f&1&~rEfov3#N%3A!ueUHX2?pokRS<1WDcBIZR&0f zGjd)y7EYc&PB)`2?L-UK%4otmf5P;>xZ0=0v!O!RR45GcrK$0sO2kz*8LrH-x8foh zMc6>7dk=^b+E_YuKRa-eb!x2^jBmuI613y}gt=o(ZcfFL;i%fOKdAPZm>q=KJ@w4^7bCe?T}98*0(q3=G{qo-SodosLvE>hy2?zk59O9VN3x;USC zsvPK)u}!GXE?{CIg13+dPEcazeAdTy><}b&5-e2>4qQ5pLe@@GQrImhCDra3>V#&l^|3m3He9B} zD;;1bDrxA8YplRzSWP&cs>DKmb`C~J2*=5%0^^`@ALw^xgDTKf>|OY1X}@3Ml0$3Hng z=4q^p591?(n~zS({l<*NKgL#2;ytM-3%{kmk3(u^&aplNK^}h)#JJe*tqj)ab z*5)zcJsFqi_f(4zQ}-*;zKB}y@is^M#n!55|6V1MQfoLQ?&ecMY9bg9XLRH$LSQ3Pm0@IyEwPs8~UI?6T^{1Uz_V-WXGf7GYp0-+qON_dbJ zI7VT;lIUfTC47Y&2Gg>M)%_-`>@5C#$Tp>qk9!1<3P(9Ofe>#?IK%=N3Q34i&EkP=_G9JPcgrz47h04_`o6rpAj#O7> zU&51wb=6Ziu8F;Je>}tliH&~Ex|GT525~~Qkj1`l1h^4Kc77}kD`QcarWbuwq4zyG>Q@j{I~I930}Z=xM_(Q z%lXYs#^0QH3EvfPFSEZ!mHsZ3Pl_iuW<0!Lsb=D@;QKO;f8z&SEWBO^e#FJ%q=Gbc zGrO6bB;m&*08UlsKaueoe#$a>y*n;lbxEBAKj)XIlH|TE;lwWmH+A?G7luTtE0J;U z^6J)tx%xNwt$_X==Upw9C++rAEC44J)g+i>IX?FH_=8Z{9|=p|JHCWJaYoCqE3DiU zX9?d|eG0tfe{=B{{8h%E@g`HNz93PP(dVj$S_l5lDhi&)0>@%#qRww?6w}h_V z7QWm%nL7V1K;Gf`M~gLOQaNFD!RFyTa(euFOB%4v@sEAW_2E%)aK#;Pdhrv%%7SrrusaPgif0WOXpvE0ZIF{I;_B+WbdiBFJ zjnDTwXa=i{>og~)tdLHci5CTbE<<(}hD%3Mbb zKUz+cT5d503+O_b&Zm=+ZX|zv=1Hc2(K4`vHV8ug{K?jkL85liUl~^mMz7?gP2BI& zM&gNQS{|Dcb(qszg5zVf{~X_Fh~n;~t+cI}J+YHZW;X1c6c91@Fl`sPDA5w_6Nl~O zD5E;MgtJm2d{UHVtltD>NFGh9CLJcXvSHSI2T91=HinW4;+r+ibeoklXo*x$pzllCUs3;&s=DMK8xE&C6g$I^dg|sEn zr)0WAWb>+md1$jg^6uk1tFtH}11I5hybQgWLn9+F%npXg+na9P5 zri3KAyRZuvqAGvUy&UX3%kR_Td$xq5swT2Fah>}-eW94{qc0jcZ&GuwY>jl1%r| zmrJpUFgv0|yFyB3{{2rS7lzvk5*nxRm4$AJCO^N>R%=!jD+?KBG<9!t(u4Gnj3WBV z3_Cr{{xEiMIJQ^Yqc-{tQQFj`(yauUxqgWr=SFh|k1=|+kXcR=m1edm zP@g746jSv7ZPPq*YTTNMyK9t4q$4c)HTo(YlIb8FW|ioK;s0MIdyn^n8)?V|&=H=| zGW(vf9>OE;SUpu|B&x=$fsrG)2=F*-=RuUG6T*rD*?RJ+KR3*!aN$pRU_D(^c^#K|1T%b z0*9>Dv?Py?N}q58W6t8lbCyKkmFZ=Xsf#1bp*Vkvt6)vODq~7ieDz5R*>;R)>lOOG zIQ0kF%iqi=3*&0b&k7UP`H{Hp^JG3@_?`5c_{_1Ge!>mTxXZ9C#?QF2j5e7$JL1A6 z*PC*-n3rGBFJ<~U{mO}m`Scqh%Z~i7ER$fHR{NbyuhQ#zP2!ebHXv7yKXA7YOvS}D zrn!GK{>s5zWj5ZRKgsk*;=!&wA-*HUl04Do2{E^NaxUnv^rk@n8({(4u_4pN!Q+T=*=%M&!i6V^XOloK$++4r zuDmiilxe?9CuEe+NZvr24<%WMbBLK<$K`*JOu{%x4$1bx7OTZ3TPzkHhl_#{EulIs z5>J!#s)glhS)-*QAtQAeos;TG_Nv)#DXUG-MNOM?ny64%+^%$`j5bT8zXpdz=AKP_ z$7Azz*aY|R7_dcjeB5G*DY88k?NZ~JmC~^&GbBy$XkP>g;-3}8aPWV!IQi`rezt$| z->9od#|$0iI_BwE6!7b)a@FW)96)W|5Y9aUp3eM(aCHu&DbQA@qdATB^?tGGKZ4DK zM~AT`FpO=1oprlh7pD=>aY?6ZmyWJZ9cmigM>hzF+BEhYg@uotjv#t550$9K1+ZAA ziSNv?jqoe0Z9o%DPMO>tok=}{LNQ1IPxIx|HIgC5c{R8bo^_n04!(F zcTLdpBHM1`>2;k*AnkKYWn_Ofusj0GHU{gK>|<~n50d>ed@w^h3~2ixN`^@eq^abn zm+=_X=U} z)|b&mI<=W69D|cz-S#2c-06R|1zflA8uQh8h%R>X!;S%TxY=Kue0J9@yw3h-uzacQ zdf`Imi(nitTy;zN1ARPzO zCkJrOFx}3GwH(iZ`eC|*{r}Txy7LfyZo@F$6KM0+9-_fC-Cysv*F8p$0gs4$XnPz| zV1u>3ql~^*J48>4M9F`N%t(iH8%`g=y^zfGXZ#$H@Q{S#5?%n_!BPoz3(u(8aIzA z?Z)ZWt1#bs9TtCBpT$D!oAB69!y?-PRM_gU*w%m=vQ(PB>8(xEbL_D?y|6(9S)i@9 zj9%mzGd#S89sQ*NR4~fRfwnY#kKcb7Xgh||`X_96L#}_fE@>O&S-q|P7&?aO)xgdT z4Dn-~e%i)vK0m--K19FP>9=Y6{Sf`7R;Rz0(LZ(iH}95k5V%%x6ueWbTaby0x(?zu zd#7$GesnZg{MOozGD}I_xM0B?SVwTN=oQ$2tAx9AbI|lQIt;71*h+KQYV+7?3)yOy z^1H?88TcNzWd{Fsf+!6uJwT7p1OEX~O9u#ciRbx{5&!`BCX+!>9Ful=F@L>o!Y~wt zub~jc)CcI~nc#tLBw1InYA7 zGOf|2)%{~*=p{3&*!9`}zE$X$Bk0M8_!>dBTrXE^`8fk2V7{}R*R-ve1Uy+^s?wuj zQiDh5$g8(kJ2Y(3B=8{M@rXSt#->#7`y=T#f$3Dh1QPg@fN}H@jBij&2MA`v`Mu4P ze0p7f%Sr<=6o&s;wY845t(T3vQP67JS=3^^;6@MyMNlYo8E4XIZ015TtqZZ>Be^Jo z3m?FT63w&rsnp9+c$&2*auy%jHudwv0n>#T$8C|-xGT+uR*MC{mgdO7d zv>_`sTJ>hV(X=0PU<~=2XrNtjDwSZ^jI{6gntMt(_nODC(9U^i60X$7_kNPzpT#so z@s1CP%h#_wt+NGP`GR-Atts%;{;a_FtYr2 zEFw!rM-rzrD=VyDZ`!n71LY`;x@&2h&>$N!j;$N8L2uZ~1}mj>ErqtM(5EOZbf9v17d>|6tV!f1it($yI;0eAx&~gaa+>blcn*Xc3w31eNXa$`!&FHL)460-D=~2)Z zZ|O+JP;Jj@eAo9*$+#bJ)LlaE@d)e=}p3&m)dDn6}3?Z(}Gh4)SivS2SxdtaLr{} zAA!L#g$7o;=(1BNDCkg|UZ;mP)8){K=r93dn<_0mZ76>)bi z4~!*BF(w}uJMt-wX>A2F`{QbXSM+XaYdU{ztBWKH=_P41noY$rV=di?e})K!8)Lgo z@gYdG3Ubx@Peh@?^JX^f%4a|ZdugACva}x_l1WMg2TuiSOn_7!on;(r572u>UNY!xM`w<2b=DyCvXf^}l%m}2kxvtVpS9i^He1RRzl$`_8 z{+vGGp_}OD!sXK!81xrRf9mRXgZ>Z99V3VXj=HPc#o#|oAMwx!>7x)pc62b3fgBA? zb#{uAe4IWZ(z&&8td{JK;Z$;8WI%%br*KX*o=lrw`m|)Z57K9F&Y%`9ZbBGt_tIa| z=j4#X;>)nmIi-_2kpp#V`#e;cNp3Os7b3zbu2KvMchX%Rx`XZ(f2d+6c4vmYbPw(F zP(9ttw4|=%%#rLqA>oUXpl-+lae3)W5~1tquZ3t}vkdt7iWS`NN1lLjwGE_`@odHv zy?E(s5|CEV*I7BQ6uXZ_HM|QXhbnnUU?>9Z4K7=iSd|C^npIpg5*Z5&NA{Y50nrwSZ26+e^sI*nHY+tMgp1PNCqEfV0HQC-r)#5r8hgWIxw0_Vq<130Cyde zyR5L$ln)!t`FVt`fS6PumWFqyBt{{G56YH6Bn8s)ehj(k>+;(JL#gBl!aydKO-sB> zYoOUIjFqE{z|K@W6j+tUX#Si)SJD!ZHCzms1?U?JtX`E~f4wTv7R^8*Wy68|#Feph zEE5>cWJc4WmV7L1mav$I(j^exA`#1qMw;~EglH!@1YN~&L%H}6!8?a6X$^>x1hQ#} zClZK)Nw8bKG#E&mrdCQImPw1W_r_pUh=E68Iz!3SNCftNL528pbp>cp$*{jc$HWpJ z1okb>ZqFt%f3Xo0pwP@mmkAF|N;;nf;L)2M9ZeyenegU?9~gu(cb_f zSvC$NBM@nNG_Jo2{u3PkF>{fvj6rf(rkf0>DlBw{B2G-$XfksUEp+U!8} zcj&va{(I9dDqCXE58&#k$GVd#b5qm+7Z+Gr+5JWDj5pc>TE*@?J#*Er`24J2WJLXj?W@mPKBJ zq>#eO2bnJW({ot)L7?xK^eahXzZQj7USk=+e|qS3`VG>l&i-YjwA6J~mi~-+^DX+V zSjT@sv6U_~=yx#XeLxP-+1ru~e@}mqkI%?1f8;t(6wAb=(w6^MKx}WD|uaFW_-ma7M`)L<8i zQ+5~XxcngoS6Sw&6Ccllxw4mMGjWTHYv2^v4%rWy`?n3vP=0iJc`nx?sPR0ez#prZ ze+{0G0F}-Tr1f?$FO*DQ&wgCl?Bc}&%Q`D!0$#{VWhE-!#T9NTq#}a@;7B}YXDY^Y zR^4==)t@TE<-Eef%XlSXZ6?{)+tJzCo2Tp?OBkyG*Sj~ab#XmRWao~}P3v9UXi2<% zEF)oQekHL05pfeY3&kyY6ed86QJrS_fBb?SBA*Pmw8wTA5E7$Ab=GxSi)*;m!)tk+ zR7iKpw+m2OmzzX8G9wD+O2XQ}7m8_IgdLY{Z`;_lsefnhrtSS(wsmiE@g?F@T6{ax zW@<}PqRZ@4ss?W3b~i6zRIA1n+qYf5sbhzWQJvNxDM~Ob)yaIBwQ|cT)LABVf4N1- zRvZIQ6^gywZH@ImYOjqom>Y-lV!8?)L z_{wSr_kp9jQx?=PDz@b%iN_LqlY7jTE=_Q^nTZTVv{($j8tyYv8en>++)ncQuwytm zxCP)0#ly<3WO7e-v_i==)5aZRf1~D~HfddX=Dqc&&vu{$??sq|pswL-J-mzitDSru zKt&WYOt)5yjrrQWqQ?IJ=uf8t6tXD)Qb^#MP7RH`JiyVpL=5yaO9wicmm9r2#9<^v z-Yr=XDcar~kM42t^-Sk>B(ngD`UOOrU|>{lE7kh}fWd&g=2m0~M4 z>xlx~`5S#x%i%GwRovC;J*S*CG!NJMXe%OCtzh% zBvDkp4KqH=RHz(k_)91^7}c>yqj@Rj;$C`+{!s$q{pD{B+sX*}8bn_rMDGM6M<8-x z(!~z|-_7{$ZtyXAe+Ek;&*rGp!w>QV{9rrOAw{H%AF|9Z|BU11;{v1)F{&F6#2Pg> zo0;gaSml|5h&*mEk&EFv#@tz6|5Rb*Si=ckx$bmTg$Vde~X`jQ8puvWL%7z$Gfqt z26yo@r@YwCSVNYZ-nYf{zEdi`iox@=YRbdk6O{9P$T+1R3wI>F{H#=QEBHCUwzf=0 zp5Vbo3plYJG@1hib*xIe_+PA8M5Lg%<|N|ezrlQCX)BVxs7nj}cP|BKnOMzBqCc&8 zffrtW85n{3fA}ZZY^G3``ieAO0j`Qboqx*z;YQ^9832h;kxiLJ6L=K}0Z06z%pS}3 z8vHtxJSqe6gqMFMM)gZZjp3{Kl;Y+8v;fC%bR5VRm<%xc7XMbpQ7X*VHqZ_&ZyOrw zPJ8)x*3|F4{3uHS%zuD9kqnWFlBu!>MV$gV^W$LZe;qzqMQuC7~$@pI6pXDna(c-gz^e-=FK_) zs%0LvNG+G*skq%DH9x=7qZX=F+Iu2{U9p7eRjUz^Fj$8VsSy38P&g$1Z$)NF0WO7d z!APc?#qV6I$$HMdBL9--RV^sj`37}vA(L4@e;-KCHz)~(j4 zR{g5lmD~qxq$`4R>P}x>siact)deU}R0#C#ivdyWOS{yC;Ii<82uiJTdk~rRnm1(4 zL{wgqyy{|giR`}=&OQ)JfN$(`?Qzo z{IsEVhzO9kHz!kGbtUccsGSP%e?i@hFA6FwYp$C1lj3ImLYNUQal$q73~rYPQJ3mx zy72$%*>L)K@^YvmI8qG&QcX`@sZ@3}e`5}ox%Z&l4UT5zF{lNLPJPs}Mj@HqY8co^ zp;~sw3g>y%9>GMPP=J07CDVq-0MeMzY(~&S02VwFOUpk}NQajS$}t1)wrxlbnt zHL8@ISBKfg+LMAf72Zq z-62FURf|LRWJde3H#Aw zwGX`@IkAT*&=3yqnxJ{dX?}R?1TAQIko=7g(&FHQw6y6#I{T>3jc!SQ)=qv1H#*VI zZ^+T=+sHFXEnCOw67<%Dw?0YEfAOg|8jjPtaQ6hQfAnHotKDZ`dJipa@Y&A~ha5TD zaF{$nKfYYjce9Vle!EcAtQ^i|0GNSE;l88BJU*U)Qk>~#ctolU)SAMo2JDF=m2hM|v# z9uspVIy-y^Q}0n)@<&9aFS~46Tb5`ZH_L z2ic$xiDJ$ojTtdQe;7J61*tnuSxWRaxu z4s9zfvg}a)LiD}*R{deuCNLWWIAH>J!USHxcenhGk@xdqa4pnc*r2U_4VrI;yPlwi z#z!x*UA%P3JrrnMdVa_*?$O|f#&%EAledw>cK^*b=;6m$fB)6{i;}@jC&_2fKQJ%; z9q^dk?$qXo7%PQU-nib%N2PBZr>Dj1Cg|Ha`o8%2zlfiI6kmX{AEy_^QdYoH8eujs zP11j8=lt1WOLxEM8=v|PtZN|Hbevuf_sCe@KHr$2H*@sgA^RM=n5YBB z{0^HBwt8ZGf9e-aVQmFDdIx{Tr{2G{N!p@Kz~@gMm5(wrsez}=qIS*hJl!sU*+{#b zR_V5mT6KE?U98*9@IGm~X_szyQ9pfBhkzm74pUOMS^9`>uxWUx(dj%YB;`P9YW8^#K^q zpJB)qhl+>VCGzAi!CCgwtPWzQ*q#SAH;4@%K?~Q0b!!W7+>52iM(WkZhQ?#;>^??k z!Gq4HXulVcyk$>)(XXtv+(bJ(xT@uHC3kGUm4G3I_CVqSk=J?zkuQ|?Z!+yiw3KO`^=j@;0^))MV9Fmyicl0AD zA{(D61MoaXs4wpqsb4UBb$j7GoHZ^&_t za@sNODjq0SC?~>>}l{DO=ah8pMGBctG#&i zG4f-F-x+Ltm`$RsB3GT?x)4HnSTky9MmeVU?*l?I`LfJ9G= zidr5)wnMlr?0e`j?&h9J&d%e#BelH$mdH9ghn#-rlhm6Zop1m2=V|kCJ`fIzTHX&{ zfB2>x-y&-NP`LX^TIzQ;P4dU^XAV7e%OoEfC(l;DvwNHpK{O#ChX>&85x?^ZIyZ<6 z)9?_Z00DQO!ydW)V-3jUM`WsOjJqEz5Dt!O`8Lh*{v6*?%Xf*~jA?u{1oK1`clll5 z_Y0a|Y`nL$w79^SVS&y!wLIQ1!C!uif9AsLP4xob2Y=tI+Z*Y_((*I>1Jw-Ac$r_- z?XUSay8SOcsoRr^)md;Bn{w*5O3l;lLUopI*QqwO9c{bXBJ0)Vs#~|))h^v$t0KCM zs;qAJt6QbD;t1bOzA1z(gan7dsJFcaFF@m?si)5B-182Ny6B5Ke^=Fz z>+}_RQb(Li$$k1X=3Mmqd={%NKcJA_tQ9hSiv~*itR+~qKfG-qk~L2LPe{BoC$fBKMO43+x|V^li%Y&p4XIKN(ic5%2sU!&yh zL&aecsXPC$Q~-bhjkl%^KsyHCf2RX!3VhoTNCh$0;9D)|T#Gci1HANMv>&@aj5EK8 zkoy(j$D_aj&m&L0j1=)xNcSYwFp2=Kr@4GS3beIQ><;qrFwN&@XaWD47OEcdqXt~0 zZllHOMcg+>-c|BIA%i=lAlT6uoaC<_4fiyJkI~{nI5=7i2eteSdwyVz&f-&}L} ztycTe2HX&G+(A{f{I>`egozLmDIbPIb>pfrjvY zCusXe%a8jk()%m|fpuiLdch^O=T`Pe$g%?5DWe?@-#V-WJqFqxl2 z%pa%E==QU8M7KxjMcuwcujuxd^rmht<=kskE)M+%9^@7E2`VU!u#O(a2^2^tXpX@T zo}hDdoM;xrTUZ1Q+x6C1`KbK!eVGz$QNEZ~Sc{DcNBn`dNO@%8ZV-yHR({a5EXU=C zZ=lq*eITgOq@$wBe_kk4nIK`LR{qgplvXG$j!g28!vX+a0RVV{kll~?9S`u&{TO;J z$8U(j{Ptt~X2@x4HJq)kM!nbNGY-=tz-Azl|87OHf4kM)*yJ;O(A$ZiE{~z376B1k zf>Ixc3|%1qO3=f)aL6qsa;wMITIH+qdG4XJ{f4g!RkBx8e^}*r`@OnMu7XBi^1A_Y zjE5BTV1022lzx|da|xCP_5jpRH8ct*$e?wAF_X$0%&FO6 zYMkADK9}wfoR z#W*GIt%6|M^1ceONhk7CgA-tkc)d$q7jAXk$;*AlZ6{XbwWkx}4fjt1il^HjSH}wMv+#TGatox#zgrBt?hXe-d)SbL+#qYSrbo{@z;EW$%aY zI(sM7ww&sPWOM30hbbd+=n*Lhh|Q_1A%OmVh`?_^2*=emSU91sZGt$oan=Fps_Q~7 zyF`VM+lE-@cR_$E3hllCbXkMHi{zvse3{=gsjknd5pay2t4Z3XCn!*~6gz~LJjGIi zjc^W?f9lh*jbAw;FeeC4C-HwTUAK4ef8DyhhmYv?7=KH*-{SA`kCBkR&(G=p zPxv){4gJ^nE$ORU)W>Z|X}VR)1n9b7_obEo*VO~MFRk<+RFk?dt@IyOkLbR%(tlh% zr~A@M|A*>Fx-YHtf2w|=`_fAPb@hhsODp|1)k)o#7JZw?RxN$qqHn=Lm7!(t&{hq> zfBN30nv>)eFv#*I<<7@l9eM?-Sx*y)zw@1jJUmohV9;8YcdQ!yjhxu^01LoMVy&~sO0gUjAu98*ARGn@m1 z5mMXFK4=3p^Fe_~iI%QXE>ZqxL&qF@4ba6L5S$0Men^V5#3@% z?`=tRL99gYEeKW(LX_yeCQ1lF2+rDk=lhPc*Y*C`nfrO>nP+C->)m%|?wbL;Q-kg` z`E^as{3>Idn(xuPYKM=QVx#q>MmB^uZo{8eD8dUzZYGl9S7P!X;SsJ1W`40To<+sl zZmAuptOEGiqSj`)Ak@}Q5 z?>oTgT>`VsnwdgG=@rh+S~b(y71}W^0D$oTwH!~9DGGhr^@ua|QLKfWyPnG(TP;~` zQ8suqp7LdsDCoD?0^wj+P&GZsn&t3*E54`^DbyFuEcqPa3+H`QmzJ|@can3|x~2Ac zsL|ZK9o+zdkH{)4WG~Wp%GtT-a(qWq(q72-zQq{=?~xz(ifNc5etXe%SBkiKnwsyQ>Iodb3ly$!bBGwBc{OTwHh&aWsb`8HZVv$=&hE*qBSl>0A-YSX{_iZ3 z7y}CynY$bhT3E?|f#iq5Uu;ph>W$=1s>@S#ym2%EGje)E2RR>7$s7Z6e@~ebe$#7g<2KNa552am6cn}j_JUmoE z*-*k{Pkde>4qITx8!H4W_47<)BKiq*ka_7tj0>sib$JLMadZF1)zZ!EGBL8j zf+c6`Lx)umvSi^SAh1*UJxs0c@HZUze8mG`>KxB5H6s$OgJhGa z0}5<49xg~@kd;6zTS_FW0!q_MTXeG z%z&=wNnTr>A_)wZ-r+)3^wCkn;!jjw92Y+y^a!M>cBo4y5P)C7 zncB>M(100vC*AO4+hxa4uVhKR&(KFS`3WjTaTIAATJgFn>|w4YlvE$7+QR_T2w|cm zF?Is-5->7?lc}|w$*Z3t6)uXZDJ7A+_c34@--f98+M13#=`HDJpO^>Yb2(2&w*PAT znEx@KU;UJ-gGE+9tDa4`_G6&rQUK)o>NM6IExXSLNc3aL{8Ioux5GJgV@&n?bh8*% z>lRZlpblvSxTwS%DI2n|!8RqOVe$A#+dq8AIoJG`l@9Chv9Hi(s*c{UGL=wY57Wma zPK~6>teHgq-*Dv!uYLd2G+@Tzz+v>jbRwewwy6C@#b-BaHv6bK*9WHF^#udtG!>iT z^Xg09;1;}4$e8`JT3kqW!lyz>2;5|QrxWrXX<7UZgOth z;U1PSH*hc`2F^N}NRHp=_b49=9*e$F%){5vni-6+jUo!I@nI5=mm|jzM+l70uVinW zF|nIe=#jTFLQCnyEr`$IF^t)^+XCS7{^u<#6J+#fx$yx^OpK zk0na|$R%PznqstOkjn>bbs;uxaS7GEjW0}S*I%l2%j2(cr6>t|*305AG7}PXnyOFp zA>O~5ZkCp0P4D|L(b6II7?bh~IaCo_MZ_E@-NKv` z3{M~v^?V_y7sDwK#N2s-A%(vWo@Ut*^^8#30m**|(Za&O2V?vRFZJ>a=B&H;rde5N zx@u`~#eoKY>>KIT`NVWM7?^vM z?UC!@>IS|1eMs8D>;N>^)XbV3aeBQYZ%OkVj+BfnD3L6zc61-{+#Tx|#Qdgh5b_}; z1!Mo!@ypT33Yiy^!}l6Zv~>F}$D$Hd?Y0l06U3S&z1WkVz8gYE8hP#}gnJI8g*hR) zQ{KHZPq6^Lf3>D++A6}^`SiJs`Hrfq!6V7!04WM>2>nWe_32PMKYJ-Fv3)=7p>>7f z{JwMie)4D=<|c>?;VJFteOZ;jXqu-x(=|Rj+-5%^nzaOn2(Ev>&NfP|y||ugTUiW- zGRkn*V+XYTY}!Z!-OU?vUni@1cVC@UllDP?b3)h}1n81z`8(Yh-YH!zX+80Z!dB*K zEg1{`>rD9*_bfB`mz_PB!0CLQx?3z`L(REHCo4~JQitvAPraia*Ea8I*U8MtN}7v$ zmI^MVl`g5zYZ8dA8GWF>53K3EY;;N^l>99-oXa3?S{jSWM*c)rj*wEmp}>N3>?_$i z^D~>MAERqbs&XMg@5n$Y>Fm8-E2O&cCV`$~GRk5l7@Lv@8aEF>+APza&n9|B6N0YtUCKEY#R7OND6N3>Q@Jk(~* zub>ZnFpVNZDA+}3iYkb(!Md*v%jzM4t!PA2H~pbQ=3R7NM~*T>z9=tTS3~Rnz#BX5jEAq z>^{dE{!#Vk6koTTdzi?Ji}Z^0xXxQ3A0YmPnm7!YA&#|nwi`i(99RW#JtcA;Y5mmq zoT`<&(6?#+Lv*J($upT?qaa_bXSoxaJMRn3_}&sG5FTj%QO+Lrzcyk=-s=^5YB2&&51@6JHSWLAxXd<8h6odpMz>jyJO0bZ-qqS*gtz zW>Sacr;9r-9QU!sO_L3o-E0PizaCV@g6=}6$3zU>yso1P0)mSzv({&;DkUH-be(co z65WBK_eSH-jTG)Fwxs=rgxOIihq?%%Rq;I<_Vxni>BI_&@k+d%BSp&|ecqLGE#tLiS-y08>p`ho4=DJ~ddcB$U zE%E%EkIc;;IgE<<$yf18_eDJj`<^+M1T848T-lKeAYZZ|=25OM!#)U!pq!6t>rdzU zH6v%HfphfR@^gFHimlIy@tuV(!ImFt&z{X!oPl2w#F9oj+h*a630%{?#Cb9lMf$5h z)lyS|ZDU6138bD`+iORDlS%P`_K&vYo>-<7dw22veSn!g{czSZ<;E+fP7Fa)K^{48M%(Vuv?A{i~u;OZW>@5Bm>1aQf zfnx}%r5oGBSg^3tkOkzOJ+@@U%9=0F1aqtKHhpd<#^s_P&_?L8y_@Ic^Rh6#)q>Q0 z+pc!1fcv|2Sy*z3Cl^*3L)+U9Lc3YLUq-D5PTftYloHg}d>v9(*Y2#YRzd3SEIQepfdB`VfT3c5B3KG zpg#xSN2oSA8ls1rI%2&(2)p({+pIeL4)?s*rG2NVBBzHY#Y3Lt9C9e;xC4u`tGVu% zYSqP}+0znV&|M0d!0Y85nm-SL4-s4TgQv9|m#v!5>129pTC~sHJ3Z)VQr9_fXA0_m znAo5Dny?CX$W#-`z$cdq^%<)|%}xZHBbHfZ=yR=pN+BkcwX{X{wiz_gIK@wGr-QFbJ!uHa$s2e8Nq&+oARzt z0L~?v&st*CvgUN0jBpH;5tdj@4t*?n6#+hzlsF)INOm>c9~1&wDtG@966UZ$`dGfv z;AN^oPv3x1Zy@s$^WL@%Nod?<(EfY%HllfL*>K3Ad#lXJ!!CEYDD0BjsB4is+WCOB z!leZ>uc<&IJvctG$xvX(iKQ`L`o;W$2(276`%$04%o~cHeu#-+a#gf2 zi*f;4)~{$4FOZ6=*%$x-+bvKmF$&aQ&Wagd#(51pET=#>B1M)l!zxH=V8P``QOM`y z>b6M3Q5*n3>XtD91q%48LImrJJcIKnY@~u5eS->Bh*v2}>oQlJDu!$}1o`lG+me$J z1#)F%K{fHOC_7SAMfz3~NDBI5=k zbALOxq5?=D6H>R9=mv;|(kX+<)R1H3hyq|hNk*7&4L#kB#FjjA>|n@?=TV zd68RQ#Gn2P4tUCR1ID}|9)cy;Q=pgX{8PtH^Dl7?G7R2Z4%Sl|lxhDjnals1*|ta@ z$jR9tNc-Pm9RPsqmJbWNe?WI&6?Ui*phX>fLH;~;kr&e~u$CVxs(3#TD%zWK8!)#< z3UnX;oAQ(<9M}hcT2!z9dmRY?Fx>JX8t@m(MsxF0N$qAtkn^7Y7A6=b*CdIFdM^^( zM1jtfjcT^MiHG*TKREyZ-z}Sw925w@S%miHY4 o0DtZP035eGEx-K-gab<`qNI4LiiL#Tq=E_HOaK57m)(5(A7B0OasU7T diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2d2b8db9..c1d138b3 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip -distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip +distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew.bat b/gradlew.bat index 8508ef68..03754c4c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,33 @@ @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line @@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2 @rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel +@rem This label must not be changed. We rely on old scripts being able to jump to this point. :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% From a9844c42f4a0159a1861e1f76ded007497981aea Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:09:47 +0300 Subject: [PATCH 057/107] fix(ci): compute strictly monotonic SemVer versionCode for releases (#1365) --- .github/workflows/release.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48ce2ab6..956384c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,10 +54,13 @@ jobs: # Strip leading 'v' for versionName VERSION="${TAG#v}" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - # versionCode: strip pre-release suffixes (e.g. 1.25.0-rc1 -> 1.25.0) and dots + # versionCode: SemVer MAJOR * 10000 + MINOR * 100 + PATCH (e.g. 2.0.0 -> 20000, > 1.31.0's 1310) BASE_VERSION="${VERSION%%-*}" - CODE=$(echo "$BASE_VERSION" | tr -d '.' | sed 's/^0*//') - [ -z "$CODE" ] && CODE=0 + IFS='.' read -r MAJOR MINOR PATCH _ <<< "$BASE_VERSION" + MAJOR="${MAJOR:-0}" + MINOR="${MINOR:-0}" + PATCH="${PATCH:-0}" + CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) echo "code=$CODE" >> "$GITHUB_OUTPUT" if [[ "$VERSION" == *"-"* ]]; then PRERELEASE="true" From 502882443eb17fb1623f2ddbb71ebc8ecea62eb2 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:38:07 +0300 Subject: [PATCH 058/107] fix(update): allow GitHub repositories pagination URL format (#1366) --- .../data/update/AppUpdateChecker.kt | 6 +++++- .../data/update/AppUpdateCheckerNetworkTest.kt | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt index fb261acd..c9133ca2 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt @@ -222,7 +222,10 @@ open class AppUpdateChecker( val url = NEXT_LINK.find(link)?.groupValues?.get(1) ?: return null val next = url.toHttpUrlOrNull() ?: throw IOException("GitHub returned an invalid pagination URL") val base = apiBaseUrl.toHttpUrlOrNull() ?: throw IOException("Invalid GitHub API base URL") - if (next.host != base.host || next.encodedPath != base.encodedPath.trimEnd('/') + "/releases") { + val expectedReposPath = base.encodedPath.trimEnd('/') + "/releases" + val isReposPath = next.encodedPath == expectedReposPath + val isRepositoriesPath = REPOSITORIES_RELEASES_PATH.matches(next.encodedPath) + if (next.host != base.host || (!isReposPath && !isRepositoriesPath)) { throw IOException("GitHub returned an untrusted pagination URL") } return next.toString() @@ -340,6 +343,7 @@ open class AppUpdateChecker( const val RELEASE_LIST_PAGE_SIZE = 100 val NEXT_LINK = Regex("<([^>]+)>;\\s*rel=\"next\"") + val REPOSITORIES_RELEASES_PATH = Regex("^/repositories/[0-9]+/releases$") } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt index e149cfcc..93687a75 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt @@ -107,6 +107,24 @@ class AppUpdateCheckerNetworkTest { assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=2", server.takeRequest().path) } + @Test + fun fetchLatestRelease_rcScanAcceptsRepositoriesPaginationFormat() = + runTest { + enqueue(STABLE_BODY) + val nextPage = server.url("/repositories/1270568813/releases?per_page=100&page=2") + enqueue(RELEASES_BODY, headers = mapOf("Link" to "<$nextPage>; rel=\"next\"")) + enqueue( + """[{"tag_name":"v2.0.0-rc.1","prerelease":true,"assets":[{"name":"release.apk","size":5,"browser_download_url":"https://example.com/new.apk"}]}]""", + ) + + val info = checker().fetchLatestRelease(includeReleaseCandidates = true) + + assertEquals("v2.0.0-rc.1", info?.tagName) + assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path) + assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=1", server.takeRequest().path) + assertEquals("/repositories/1270568813/releases?per_page=100&page=2", server.takeRequest().path) + } + @Test fun fetchLatestRelease_releaseCandidatesStillIgnoreAlphaAndBetaButKeepStable() = runTest { From 503aba863fd987b5e27dc2be49dbc6d3ad975750 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:56:37 +0300 Subject: [PATCH 059/107] feat(#1367): render agent MEDIA inline at its directive position (#1370) --- .../hermescontrol/data/model/Attachment.kt | 4 + .../ui/chat/ChatMediaDelegate.kt | 1 + .../ui/chat/ChatServerMessageMapper.kt | 1 + .../ui/chat/HostMediaExtractor.kt | 10 ++- .../hermescontrol/ui/chat/MessageSegment.kt | 84 +++++++++++++++++++ .../chat/fullbleed/FullBleedAgentMessage.kt | 63 ++++++++------ .../ui/chat/MediaSegmentsTest.kt | 62 ++++++++++++++ 7 files changed, 200 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/MessageSegment.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/MediaSegmentsTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt index f9b2bf17..c45078c2 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt @@ -17,6 +17,9 @@ import kotlinx.serialization.Serializable * file that lives on the *gateway host* (agent-delivered `MEDIA:` directive). * Null for locally-picked attachments. * @param source Where the attachment originated. + * @param contentOffset For agent `MEDIA:` attachments, the char offset in the message's + * stripped text where the directive appeared, so the renderer can place it in-flow. + * Null means "append after the text". */ @Serializable data class Attachment( @@ -26,6 +29,7 @@ data class Attachment( val size: Long = 0, val gatewayUrl: String? = null, val source: AttachmentSource = AttachmentSource.LOCAL, + val contentOffset: Int? = null, ) { val mediaKind: MediaKind get() = classifyMedia(mimeType = mimeType, name = name, uri = gatewayUrl ?: uri) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt index bb8f0afe..a4f8b891 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt @@ -60,6 +60,7 @@ class ChatMediaDelegate( size = 0, gatewayUrl = url, source = AttachmentSource.GATEWAY, + contentOffset = item.offset, ) } if (newAttachments.isEmpty()) return 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 53bfd0d8..a620b5bd 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 @@ -247,6 +247,7 @@ internal fun mapServerMessages( size = 0, gatewayUrl = url, source = AttachmentSource.GATEWAY, + contentOffset = item.offset, ) }.takeIf { it.isNotEmpty() } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/HostMediaExtractor.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/HostMediaExtractor.kt index d969dc58..c47d67ac 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/HostMediaExtractor.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/HostMediaExtractor.kt @@ -21,6 +21,8 @@ internal object HostMediaExtractor { data class Item( val match: String, // the full matched directive (for stripping) val path: String, // normalized absolute path + // Issue #1367: char offset into strip(text) where the directive sat. + val offset: Int = 0, ) /** Matches `MEDIA:` (any extension), supporting quotes/backticks around the path. */ @@ -33,12 +35,18 @@ internal object HostMediaExtractor { /** Extract every `MEDIA:` directive from [text] as a normalized item. */ fun extract(text: String): List { if (!text.contains("MEDIA:")) return emptyList() + val total = strip(text).length + // Text kept so far, with every directive (valid or not) removed exactly as strip() does. + val kept = StringBuilder() + var cursor = 0 return MEDIA_TAG_RE .findAll(text) .mapNotNull { m -> + kept.append(text, cursor, m.range.first) + cursor = m.range.last + 1 val raw = m.groupValues.drop(1).firstOrNull { it.isNotBlank() } ?: return@mapNotNull null val path = normalizePath(raw) ?: return@mapNotNull null - Item(match = m.value, path = path) + Item(match = m.value, path = path, offset = strip(kept.toString()).length.coerceAtMost(total)) }.toList() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MessageSegment.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MessageSegment.kt new file mode 100644 index 00000000..95bd57b4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MessageSegment.kt @@ -0,0 +1,84 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.Attachment + +/** Issue #1367: one in-order piece of an agent message body. */ +internal sealed interface MessageSegment { + data class Text( + val text: String, + ) : MessageSegment + + data class Media( + val attachment: Attachment, + ) : MessageSegment +} + +/** + * Interleave [attachments] into [content] at the position their `MEDIA:` directive occupied + * ([Attachment.contentOffset]). Attachments without an offset trail the text, as before. + * + * Offsets snap forward to the end of their line, and past a still-open code fence, so media + * never splits a sentence's markdown, list item, or fenced block. + */ +internal fun splitByMedia( + content: String, + attachments: List?, +): List { + if (attachments.isNullOrEmpty()) { + return if (content.isBlank()) emptyList() else listOf(MessageSegment.Text(content)) + } + val inline = attachments.filter { it.contentOffset != null }.sortedBy { it.contentOffset } + val trailing = attachments.filter { it.contentOffset == null } + val out = mutableListOf() + var cursor = 0 + inline.forEach { attachment -> + val pos = snapToBlockEnd(content, attachment.contentOffset ?: 0).coerceAtLeast(cursor) + addText(out, content.substring(cursor, pos)) + out += MessageSegment.Media(attachment) + cursor = pos + } + addText(out, content.substring(cursor)) + trailing.forEach { out += MessageSegment.Media(it) } + return out +} + +private fun addText( + out: MutableList, + text: String, +) { + if (text.isNotBlank()) out += MessageSegment.Text(text.trim('\n')) +} + +private fun snapToBlockEnd( + content: String, + offset: Int, +): Int { + var pos = offset.coerceIn(0, content.length) + pos = lineEnd(content, pos) + // Inside an unclosed fence? Advance to the end of the closing fence line. + while (fenceOpenBefore(content, pos)) { + val next = pos + 1 + if (next > content.length) return content.length + pos = lineEnd(content, next) + } + return pos +} + +private fun lineEnd( + content: String, + from: Int, +): Int { + val idx = content.indexOf('\n', from) + return if (idx < 0) content.length else idx +} + +private fun fenceOpenBefore( + content: String, + pos: Int, +): Boolean { + var open = false + content.substring(0, pos).lineSequence().forEach { line -> + if (line.trimStart().startsWith("```")) open = !open + } + return open +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 318f3806..ec21135c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -36,11 +37,13 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ImageViewerModel -import com.m57.hermescontrol.ui.chat.InlineAttachmentList +import com.m57.hermescontrol.ui.chat.InlineAttachment import com.m57.hermescontrol.ui.chat.MarkdownText +import com.m57.hermescontrol.ui.chat.MessageSegment import com.m57.hermescontrol.ui.chat.TokenEstimator import com.m57.hermescontrol.ui.chat.components.ReasoningCard import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback +import com.m57.hermescontrol.ui.chat.splitByMedia import kotlinx.coroutines.launch /** @@ -96,32 +99,44 @@ internal fun FullBleedAgentMessage( // lone Copy button). Blank rows are tool-call placeholders that slipped // through upstream mapping; the parent list renders the live status // indicator until the first visible delta lands. - if (message.content.isNotBlank()) { - SelectionContainer { - MarkdownText( - text = message.content, - textColor = textColor, - isStreaming = message.isStreaming, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - onImageClick = onImageClick, - ) + // Issue #1367: agent media renders where its `MEDIA:` directive sat in the + // prose; attachments without a recorded position trail the text. + val segments = + remember(message.content, message.attachments) { + splitByMedia(message.content, message.attachments) + } + segments.forEach { segment -> + when (segment) { + is MessageSegment.Text -> { + SelectionContainer { + MarkdownText( + text = segment.text, + textColor = textColor, + isStreaming = message.isStreaming, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + onImageClick = onImageClick, + ) + } + } + + is MessageSegment.Media -> { + Spacer(modifier = Modifier.height(6.dp)) + InlineAttachment( + attachment = segment.attachment, + textColor = textColor, + onOpen = onOpenAttachment, + onSave = onSaveAttachment, + savingPath = savingAttachmentPath, + openingPath = openingAttachmentPath, + canSave = canSaveAttachment, + onImageClick = onImageClick, + ) + Spacer(modifier = Modifier.height(6.dp)) + } } } - // Inline attachments (shared with UserBubble so agent-delivered - // media — images, files — shows in full-bleed mode too). - InlineAttachmentList( - attachments = message.attachments, - textColor = textColor, - onOpen = onOpenAttachment, - onSave = onSaveAttachment, - savingPath = savingAttachmentPath, - openingPath = openingAttachmentPath, - canSave = canSaveAttachment, - onImageClick = onImageClick, - ) - if (!message.isStreaming && message.content.isNotBlank()) { val showTokenStat = messageStatsEnabled && showAssistantMessageTokens && diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaSegmentsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaSegmentsTest.kt new file mode 100644 index 00000000..fd1ce63f --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaSegmentsTest.kt @@ -0,0 +1,62 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.Attachment +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MediaSegmentsTest { + private fun att( + name: String, + offset: Int?, + ) = Attachment(uri = "u/$name", name = name, mimeType = "image/png", contentOffset = offset) + + private fun kinds(segments: List) = + segments.map { + when (it) { + is MessageSegment.Text -> "T:${it.text}" + is MessageSegment.Media -> "M:${it.attachment.name}" + } + } + + @Test + fun extractRecordsOffsetsIntoStrippedText() { + val text = "First: MEDIA:/a/1.png\nthen second: MEDIA:/a/2.png\nend" + val stripped = HostMediaExtractor.strip(text) + val items = HostMediaExtractor.extract(text) + assertEquals(2, items.size) + assertTrue(items[0].offset <= items[1].offset) + assertTrue(items[1].offset <= stripped.length) + } + + @Test + fun mediaLandsBetweenTheParagraphsItSatBetween() { + val text = "Chart one:\nMEDIA:/a/1.png\nChart two:\nMEDIA:/a/2.png\nDone." + val stripped = HostMediaExtractor.strip(text) + val items = HostMediaExtractor.extract(text) + val atts = items.map { att(it.path.substringAfterLast('/'), it.offset) } + val out = kinds(splitByMedia(stripped, atts)) + assertEquals(listOf("T:Chart one:", "M:1.png", "T:Chart two:", "M:2.png", "T:Done."), out) + } + + @Test + fun noOffsetAppendsAfterText() { + val out = kinds(splitByMedia("Hello", listOf(att("x.png", null)))) + assertEquals(listOf("T:Hello", "M:x.png"), out) + } + + @Test + fun mediaOnlyMessageHasNoBlankText() { + val out = kinds(splitByMedia("", listOf(att("x.png", 0)))) + assertEquals(listOf("M:x.png"), out) + } + + @Test + fun mediaDoesNotSplitAFencedCodeBlock() { + val content = "Look:\n```\ncode line\nmore\n```\nafter" + val out = kinds(splitByMedia(content, listOf(att("x.png", content.indexOf("code line"))))) + assertEquals(2, out.count { it.startsWith("T:") }) + assertTrue(out[0].contains("more") && out[0].endsWith("```")) + assertEquals("M:x.png", out[1]) + } +} From c7134832c91ff36f2c762014e2dc29663c362ca8 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:43:21 +0300 Subject: [PATCH 060/107] feat(build): install debug builds side by side as Hermes Dev (#1371) --- app/build.gradle.kts | 10 +++++++++- app/src/debug/res/values-ar/strings.xml | 4 ++++ app/src/debug/res/values-ko/strings.xml | 4 ++++ app/src/debug/res/values-zh/strings.xml | 4 ++++ app/src/debug/res/values/strings.xml | 4 ++++ 5 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 app/src/debug/res/values-ar/strings.xml create mode 100644 app/src/debug/res/values-ko/strings.xml create mode 100644 app/src/debug/res/values-zh/strings.xml create mode 100644 app/src/debug/res/values/strings.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a28b5da5..a4e35c23 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -85,18 +85,26 @@ android { buildTypes { debug { + applicationIdSuffix = ".dev" + versionNameSuffix = "-dev" + buildConfigField("boolean", "ALLOW_CLEARTEXT", "true") manifestPlaceholders["usesCleartextTraffic"] = "true" } + release { isMinifyEnabled = true isShrinkResources = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) signingConfig = signingConfigs["release"] buildConfigField("boolean", "ALLOW_CLEARTEXT", "true") manifestPlaceholders["usesCleartextTraffic"] = "true" } } + compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 diff --git a/app/src/debug/res/values-ar/strings.xml b/app/src/debug/res/values-ar/strings.xml new file mode 100644 index 00000000..16d048ae --- /dev/null +++ b/app/src/debug/res/values-ar/strings.xml @@ -0,0 +1,4 @@ + + + Hermes Dev + diff --git a/app/src/debug/res/values-ko/strings.xml b/app/src/debug/res/values-ko/strings.xml new file mode 100644 index 00000000..16d048ae --- /dev/null +++ b/app/src/debug/res/values-ko/strings.xml @@ -0,0 +1,4 @@ + + + Hermes Dev + diff --git a/app/src/debug/res/values-zh/strings.xml b/app/src/debug/res/values-zh/strings.xml new file mode 100644 index 00000000..16d048ae --- /dev/null +++ b/app/src/debug/res/values-zh/strings.xml @@ -0,0 +1,4 @@ + + + Hermes Dev + diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 00000000..16d048ae --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Hermes Dev + From 4f14ecac4b19448e1176cedc71f5b26ce1df1927 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:01:13 +0300 Subject: [PATCH 061/107] fix(#1368): highlight chat search hits in inline spans, code blocks and reasoning (#1372) --- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 2 + .../ui/chat/components/CodeBlockCard.kt | 39 ++++++++++++++++++- .../chat/fullbleed/FullBleedAgentMessage.kt | 6 +++ .../ui/chat/fullbleed/FullBleedChatList.kt | 18 +++++++++ .../ui/chat/fullbleed/FullBleedTurns.kt | 9 ++++- .../ui/chat/markdown/MarkdownInlineStyler.kt | 37 ++++++++++++++---- .../ui/chat/FullBleedItemKeysTest.kt | 16 ++++++++ 7 files changed, 118 insertions(+), 9 deletions(-) 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 5195c84e..6c91c1d9 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 @@ -111,6 +111,8 @@ fun MarkdownText( code = block.code, language = block.language, onCopy = { /* clipboard handled internally */ }, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt index 38f6ac92..649228b2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt @@ -10,10 +10,16 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.m57.hermescontrol.theme.CodeTerminalText +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.theme.SearchHighlightColors +import com.m57.hermescontrol.theme.searchHighlightColors import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -27,7 +33,10 @@ fun CodeBlockCard( language: String?, onCopy: (String) -> Unit, modifier: Modifier = Modifier, + searchQuery: String = "", + isCurrentMatch: Boolean = false, ) { + val highlights = searchHighlightColors(LocalHermesStatusColors.current) val highlighted by produceState( initialValue = remember(code) { AnnotatedString(code) }, key1 = code, @@ -47,7 +56,10 @@ fun CodeBlockCard( copyContentDescription = "Copy code", ) { Text( - text = highlighted, + text = + remember(highlighted, searchQuery, isCurrentMatch, highlights) { + highlighted.withSearchHighlights(searchQuery, isCurrentMatch, highlights) + }, fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = CodeTerminalText, @@ -59,3 +71,28 @@ fun CodeBlockCard( ) } } + +/** Overlay search-hit spans on an already syntax-highlighted string, keeping its existing styles. */ +private fun AnnotatedString.withSearchHighlights( + query: String, + isCurrent: Boolean, + highlights: SearchHighlightColors, +): AnnotatedString { + if (query.isEmpty()) return this + val (bg, fg) = + if (isCurrent) { + highlights.currentSearchBackground to highlights.currentSearchForeground + } else { + highlights.searchBackground to highlights.searchForeground + } + return buildAnnotatedString { + append(this@withSearchHighlights) + var from = 0 + while (true) { + val hit = text.indexOf(query, from, ignoreCase = true) + if (hit < 0) break + addStyle(SpanStyle(background = bg, color = fg), hit, hit + query.length) + from = hit + query.length + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index ec21135c..3494f339 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -60,6 +60,9 @@ internal fun FullBleedAgentMessage( message: ChatMessage, searchQuery: String = "", isCurrentMatch: Boolean = false, + reasoningSearchQuery: String = "", + isCurrentReasoningMatch: Boolean = false, + reasoningSearchOffset: Int = 0, showReasoning: Boolean = true, onOpenAttachment: (Attachment) -> Unit = {}, onSaveAttachment: (Attachment) -> Unit = {}, @@ -91,6 +94,9 @@ internal fun FullBleedAgentMessage( ReasoningCard( reasoningText = message.reasoningText, isStreaming = message.isStreaming, + searchQuery = reasoningSearchQuery, + isCurrentMatch = isCurrentReasoningMatch, + searchOffset = reasoningSearchOffset, ) Spacer(modifier = Modifier.height(6.dp)) } 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 eca2f7a0..126087a8 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 @@ -426,6 +426,24 @@ fun FullBleedChatList( searchState.currentIndex, ) == SearchTarget.CONTENT, + reasoningSearchQuery = + if (searchState.isActive && + proseMessage.id in searchState.matchedIds + ) { + searchState.query + } else { + "" + }, + isCurrentReasoningMatch = + searchState.currentMatchId == proseMessage.id && + searchState.matchTargets.getOrNull( + searchState.currentIndex, + ) == + SearchTarget.REASONING, + reasoningSearchOffset = + searchState.matchOffsets.getOrElse( + searchState.currentIndex, + ) { 0 }, showReasoning = !hoistedReasoning, onOpenAttachment = actions.onOpenAttachment, onSaveAttachment = actions.onSaveAttachment, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt index 74c96e32..d8841774 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt @@ -197,7 +197,14 @@ fun searchMatchToLazyIndex( SearchTarget.REASONING -> "reasoning" SearchTarget.TOOL -> "tool" } - return fullBleedItemKeys(turns).indexOf("$prefix-${message.id}").takeIf { it >= 0 } + val keys = fullBleedItemKeys(turns) + keys.indexOf("$prefix-${message.id}").takeIf { it >= 0 }?.let { return it } + // Non-hoisted reasoning renders inside its message's prose row, not as its own row. + return if (match.target == SearchTarget.REASONING) { + keys.indexOf("prose-${message.id}").takeIf { it >= 0 } + } else { + null + } } /** Lazy row identities, including hoisted reasoning and grouped tool/system entries. */ 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 97cc6c41..eb0e6fe5 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 @@ -102,7 +102,7 @@ object MarkdownInlineStyler { background = textColor.copy(alpha = 0.08f), ), ) { - append(content) + appendSearchable(content, searchQuery, searchHighlightColor) } i = matchingEnd + runLength } else { @@ -118,7 +118,7 @@ object MarkdownInlineStyler { 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) + appendSearchable(content, searchQuery, searchHighlightColor) } i = end + 3 } else { @@ -161,7 +161,7 @@ object MarkdownInlineStyler { val raw = src.substring(i + 2, end) val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { - append(toAppend) + appendSearchable(toAppend, searchQuery, searchHighlightColor) } i = end + 2 } else { @@ -201,7 +201,7 @@ object MarkdownInlineStyler { val raw = src.substring(i + 2, end) val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle(SpanStyle(background = highlights.markupBackground)) { - append(toAppend) + appendSearchable(toAppend, searchQuery, searchHighlightColor) } i = end + 2 } else { @@ -215,7 +215,7 @@ object MarkdownInlineStyler { val end = src.indexOf('^', i + 1) if (end != -1 && end > i + 1) { withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) { - append(src.substring(i + 1, end)) + appendSearchable(src.substring(i + 1, end), searchQuery, searchHighlightColor) } i = end + 1 } else { @@ -229,7 +229,7 @@ object MarkdownInlineStyler { val end = src.indexOf('~', i + 1) if (end != -1 && end > i + 1) { withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) { - append(src.substring(i + 1, end)) + appendSearchable(src.substring(i + 1, end), searchQuery, searchHighlightColor) } i = end + 1 } else { @@ -250,7 +250,7 @@ object MarkdownInlineStyler { background = textColor.copy(alpha = 0.12f), ), ) { - append(toAppend) + appendSearchable(toAppend, searchQuery, searchHighlightColor) } i = end + 6 } else { @@ -382,4 +382,27 @@ object MarkdownInlineStyler { } private fun isRtr(isRtl: Boolean): Boolean = isRtl + + /** Append [text], highlighting every [query] hit so styled spans (inline code, sub/sup, ...) stay searchable. */ + private fun AnnotatedString.Builder.appendSearchable( + text: String, + query: String, + highlight: Pair, + ) { + if (query.isEmpty()) { + append(text) + return + } + var from = 0 + while (from < text.length) { + val hit = text.indexOf(query, from, ignoreCase = true) + if (hit < 0) break + append(text.substring(from, hit)) + withStyle(SpanStyle(background = highlight.first, color = highlight.second)) { + append(text.substring(hit, hit + query.length)) + } + from = hit + query.length + } + append(text.substring(from)) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt index 31834295..1f4f08bf 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt @@ -40,6 +40,22 @@ class FullBleedItemKeysTest { assertEquals(3, searchMatchToLazyIndex(turns, messages, SearchMatch(2, 0))) } + @Test + fun secondaryReasoningResolvesToItsProseRow() { + val messages = + listOf( + ChatMessage("a1", MessageRole.ASSISTANT, "one", reasoningText = "first"), + ChatMessage("a2", MessageRole.ASSISTANT, "two", reasoningText = "second"), + ) + val turns = groupIntoTurns(messages) + val keys = fullBleedItemKeys(turns) + + assertEquals( + keys.indexOf("prose-a2"), + searchMatchToLazyIndex(turns, messages, SearchMatch(1, 0, SearchTarget.REASONING)), + ) + } + @Test fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() { val tool = ChatMessage("live-tool", MessageRole.TOOL, "output") From b59185d9e4ee034914bc60a5f7de467cceb8a520 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:17:59 +0300 Subject: [PATCH 062/107] fix(test): stop KanbanMutationTest racing resetMain (#1373) --- .../hermescontrol/ui/kanban/KanbanMutationTest.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt index de218f32..050af039 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.kanban +import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore import com.m57.hermescontrol.data.model.BulkTaskResult import com.m57.hermescontrol.data.model.BulkTasksResponse @@ -25,6 +26,7 @@ import io.mockk.mockk import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -43,13 +45,19 @@ class KanbanMutationTest { private val mockEventsClient = mockk(relaxed = true) private val preferences = InMemoryKanbanPreferencesStore() + private val createdViewModels = mutableListOf() + private fun createViewModel(): KanbanViewModel = KanbanViewModel( repository = mockRepository, preferences = preferences, eventsClientProvider = { mockEventsClient }, endpointProvider = { "http://127.0.0.1:9119" }, - ) + ).also { + // Keep all VM work on the test scheduler; real Dispatchers.IO races resetMain(). + it.ioDispatcher = testDispatcher + createdViewModels += it + } @Before fun setUp() { @@ -58,6 +66,9 @@ class KanbanMutationTest { @After fun tearDown() { + testDispatcher.scheduler.advanceUntilIdle() + createdViewModels.forEach { it.viewModelScope.cancel() } + createdViewModels.clear() Dispatchers.resetMain() } From 30afd845e8d9a47f6fb4a16346ec0fef5100f4c2 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:25:54 +0300 Subject: [PATCH 063/107] docs: require focused PRs (one feature or fix) --- .github/PULL_REQUEST_TEMPLATE.md | 1 + CONTRIBUTING.md | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b2b73c01..93eb8de9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -27,6 +27,7 @@ Fixes # ## Checklist +- [ ] PR is focused on one feature or one fix (no unrelated changes) - [ ] Branch rebased onto `dev` (`git rebase origin/dev`) - [ ] `./gradlew ktlintCheck` passes (ran `ktlintFormat` first) - [ ] `./gradlew testDebugUnitTest` green (or CI unit-tests job) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0283d0e..e70ef899 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,8 @@ If an open PR already covers it, review or improve that one instead of opening a Every change must go through a pull request (PR) targeting the `dev` staging branch. Do not push directly to `main` or `dev`. +**Keep PRs focused: one feature or one fix per PR.** Unrelated changes belong in separate PRs so each one stays easy to review, test, and revert. + 1. **Pick or open an issue** to discuss the changes you want to make. 2. **Create a branch** off `dev` using the following naming convention: - Features: `feat/short-description` (optionally `feat/issue-N-description`) @@ -124,6 +126,7 @@ We enforce Kotlin coding conventions and Jetpack Compose best practices. Before submitting your PR, please verify: - [ ] I searched open/closed PRs + issues for duplicates (see *Before You Start*). +- [ ] PR is focused on one feature or one fix (no unrelated changes). - [ ] Branch is rebased onto current `dev` (`git rebase origin/dev`). - [ ] `./gradlew ktlintCheck` passes (ran `ktlintFormat` first — no hand edits). - [ ] `checkColorLiterals` passes (no hardcoded Color literals outside theme/). From d07325cd31008aa70c65d4d515c98f0e69614aed Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:37:46 +0300 Subject: [PATCH 064/107] ci(#1374): add gateway contract drift check (#1378) --- AGENTS.md | 1 + CONTRIBUTING.md | 9 + .../hermescontrol/data/ws/GatewayContract.kt | 93 + .../data/ws/GatewayContractFramesTest.kt | 146 + .../data/ws/GatewayContractTest.kt | 98 + .../test/resources/gateway-contract/SOURCE | 5 + .../resources/gateway-contract/openrpc.json | 40113 ++++++++++++++++ scripts/sync-gateway-contract.sh | 19 + 8 files changed, 40484 insertions(+) create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractFramesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt create mode 100644 app/src/test/resources/gateway-contract/SOURCE create mode 100644 app/src/test/resources/gateway-contract/openrpc.json create mode 100755 scripts/sync-gateway-contract.sh diff --git a/AGENTS.md b/AGENTS.md index d87443ad..d0f603b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -311,6 +311,7 @@ com.m57.hermescontrol/ - [README.md](README.md) — human-facing overview, features, screenshots, tech stack - [DESIGN.md](DESIGN.md) — visual and interaction requirements, accessibility, and token source map - [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md) — theme template, dispatcher, and mode fallback implementation +- [Gateway contract test](CONTRIBUTING.md#gateway-contract-snapshot) — backend drift check; refresh with `scripts/sync-gateway-contract.sh` - [CONTRIBUTING.md](CONTRIBUTING.md) — contributor workflow, PR checklist, code style - [.github/workflows/android.yml](.github/workflows/android.yml) — CI pipeline source of truth - [.github/workflows/merge-conflict-detector.yml](.github/workflows/merge-conflict-detector.yml) — auto-labels conflicting PRs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e70ef899..0748ef83 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,6 +112,15 @@ We enforce Kotlin coding conventions and Jetpack Compose best practices. - Mock time/dispatchers explicitly (`StandardTestDispatcher` + `Dispatchers.setMain`), restore `Dispatchers.Main` in teardown, and cancel test-owned scopes. Follow neighboring tests for the affected component. - For UI changes, exercise the changed flow on a device or emulator and record the behavior observed. A built or downloaded CI APK alone is not behavioral verification. If device verification is unavailable, state that gate explicitly rather than marking it passed. + +### Gateway contract snapshot + +`GatewayContractTest` checks `WsMethods`, handled server requests and literal param keys against a vendored copy of +hermes-agent's `gateway-contract.openrpc.json` (`app/src/test/resources/gateway-contract/`, provenance in `SOURCE`). +It is test-only. Refresh it with `scripts/sync-gateway-contract.sh [ref]`, then run +`./gradlew testDebugUnitTest --tests '*GatewayContract*'` and review the diff. Allowlist exceptions need a reason in +`GatewayContractAllowlist`. + --- ## Dependency & Build Hygiene diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt new file mode 100644 index 00000000..7a4886ac --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt @@ -0,0 +1,93 @@ +package com.m57.hermescontrol.data.ws + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Vendored snapshot of the backend's published gateway contract + * (`apps/shared/src/gateway-contract.openrpc.json` in hermes-agent), test-only. + * Refresh with `scripts/sync-gateway-contract.sh`; provenance is in `SOURCE`. + */ +object GatewayContract { + private val root: JsonObject by lazy { + val stream = + requireNotNull(GatewayContract::class.java.getResourceAsStream("/gateway-contract/openrpc.json")) { + "gateway-contract/openrpc.json missing from test resources" + } + Json.parseToJsonElement(stream.bufferedReader().use { it.readText() }).jsonObject + } + + private val schemas: JsonObject get() = root["components"]!!.jsonObject["schemas"]!!.jsonObject + + private fun names(key: String): Set = + (root[key] as JsonArray).map { it.jsonObject["name"]!!.jsonPrimitive.content }.toSet() + + val methods: Set by lazy { names("methods") } + + /** Server→client request methods (`x-server-requests`). */ + val serverRequests: Set by lazy { names("x-server-requests") } + + private fun paramsSchema(method: String): JsonObject { + val entry = + (root["methods"] as JsonArray) + .map { it.jsonObject } + .first { it["name"]!!.jsonPrimitive.content == method } + val ref = + entry["params"]!! + .jsonArray + .first() + .jsonObject["schema"]!! + .jsonObject["\$ref"]!! + .jsonPrimitive.content + return schemas[ref.substringAfterLast('/')]!!.jsonObject + } + + /** + * Keys the backend would reject for [method]: unknown keys when the params + * schema forbids extras, plus required keys that are absent. Empty means OK. + * Keys only; value types are not checked. + */ + fun paramKeyProblems( + method: String, + keys: Set, + ): List { + if (method !in methods) return listOf("$method is not a contract method") + val schema = paramsSchema(method) + val properties = schema["properties"]?.jsonObject?.keys.orEmpty() + val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty() + val strict = schema["additionalProperties"]?.jsonPrimitive?.boolean == false + val problems = mutableListOf() + if (strict) { + (keys - properties).sorted().forEach { + problems += "$method: unknown param '$it' (backend forbids extra keys; allowed: ${properties.sorted()})" + } + } + required.filter { it !in keys }.forEach { problems += "$method: missing required param '$it'" } + return problems + } +} + +/** + * WsMethods constants the backend contract does not list. Every entry needs a + * reason; the test fails if an entry appears in the contract or is no longer a + * WsMethods constant, so stale exceptions get removed. + */ +object GatewayContractAllowlist { + private const val LEGACY = "legacy fallback for gateways before srq request frames (#1119)" + + val entries: Map = + mapOf( + "gateway.ping" to "handled inline by the WS transport itself (tui_gateway/ws.py), not a contract method", + "clarify.respond" to LEGACY, + "sudo.respond" to LEGACY, + "secret.respond" to LEGACY, + "vault.unlock.respond" to LEGACY, + "vault.code.respond" to LEGACY, + "vault.save_login.respond" to LEGACY, + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractFramesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractFramesTest.kt new file mode 100644 index 00000000..b92d4208 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractFramesTest.kt @@ -0,0 +1,146 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Sends the client-owned RPCs through the real [HermesWsClient] and checks the + * exact frame on the wire against the vendored gateway contract (#1374). + */ +class GatewayContractFramesTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + } + + @After + fun tearDown() { + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + /** Connect, run [trigger], and return the first frame the server sees for [method]. */ + private fun captureFrame( + method: String, + beforeConnect: () -> Unit = {}, + trigger: () -> Unit = {}, + ): JsonObject { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frame = AtomicReference(null) + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (frame.get() == null && name == method) { + frame.set(json) + captured.countDown() + } + } + }, + ), + ) + beforeConnect() + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + assertTrue(opened.await(5, TimeUnit.SECONDS)) + trigger() + assertTrue("No '$method' frame reached the server", captured.await(5, TimeUnit.SECONDS)) + return requireNotNull(frame.get()) + } + + private fun assertMatchesContract(frame: JsonObject) { + val method = (frame["method"] as JsonPrimitive).content + val params = frame["params"] as JsonObject + val problems = GatewayContract.paramKeyProblems(method, params.keys) + assertTrue(problems.joinToString("\n"), problems.isEmpty()) + } + + @Test + fun promptSubmitFrameMatchesContract() { + assertMatchesContract( + captureFrame(WsMethods.PROMPT_SUBMIT) { HermesWsClient.sendMessage("s1", "hello", queued = true) }, + ) + } + + @Test + fun eventsSinceReplayFrameMatchesContract() { + assertMatchesContract( + captureFrame(WsMethods.SESSION_EVENTS_SINCE, beforeConnect = { HermesWsClient.setSeqWatermark("s1", 5) }), + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt new file mode 100644 index 00000000..033adc15 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt @@ -0,0 +1,98 @@ +package com.m57.hermescontrol.data.ws + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.reflect.Modifier + +/** Fails CI when the app drifts from the backend's published gateway contract (#1374). */ +class GatewayContractTest { + private val wsMethodConstants: Map = + WsMethods::class.java.declaredFields + .filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java } + .associate { it.name to it.get(null) as String } + + @Test + fun everyWsMethodIsInTheContractOrAllowlisted() { + val unknown = + wsMethodConstants.filter { (_, method) -> + method !in GatewayContract.methods && method !in GatewayContractAllowlist.entries + } + assertTrue( + "WsMethods constants missing from the gateway contract (backend removed or renamed them?): " + + unknown.entries.joinToString { "${it.key}=\"${it.value}\"" } + + ". Fix the constant, or allowlist it with a reason in GatewayContractAllowlist.", + unknown.isEmpty(), + ) + } + + @Test + fun profileScopedMethodsAreContractMethods() { + val unknown = WsMethods.PROFILE_SCOPED_METHODS - GatewayContract.methods + assertTrue("PROFILE_SCOPED_METHODS not in the contract: $unknown", unknown.isEmpty()) + } + + @Test + fun allowlistEntriesHaveReasonsAndAreNotStale() { + val sent = wsMethodConstants.values.toSet() + GatewayContractAllowlist.entries.forEach { (method, reason) -> + assertTrue("Allowlist entry '$method' needs a reason", reason.isNotBlank()) + assertTrue( + "Allowlist entry '$method' is now in the contract; remove it from GatewayContractAllowlist", + method !in GatewayContract.methods, + ) + assertTrue( + "Allowlist entry '$method' is no longer a WsMethods constant; remove it", + method in sent, + ) + } + } + + @Test + fun handledServerRequestsExistInTheContract() { + val unknown = APP_HANDLED_SERVER_REQUESTS - GatewayContract.serverRequests + assertTrue("Handled server requests missing from the contract: $unknown", unknown.isEmpty()) + + // Informational: unsupported requests are already answered with -32601. + val unhandled = (GatewayContract.serverRequests - APP_HANDLED_SERVER_REQUESTS).sorted() + println("Contract server requests the app does not handle: $unhandled") + } + + @Test + fun paramKeyCheckRejectsTheRegressionsItExistsFor() { + // #1118: the backend forbids extra keys, so since_seq broke replay with error 4000. + val problems = GatewayContract.paramKeyProblems("session.events.since", setOf("session_id", "since_seq")) + assertEquals(1, problems.count { "since_seq" in it }) + assertTrue(GatewayContract.paramKeyProblems("session.events.since", setOf("session_id")).isEmpty()) + assertTrue(GatewayContract.paramKeyProblems("session.nope", emptySet()).isNotEmpty()) + } + + @Test + fun literalParamKeysAtCallSitesMatchTheContract() { + CALL_SITE_PARAM_KEYS.forEach { (method, keys) -> + val problems = GatewayContract.paramKeyProblems(method, keys) + assertTrue(problems.joinToString("\n"), problems.isEmpty()) + } + } + + private companion object { + /** Methods ChatViewModel.handleServerRequest routes; keep in sync with its `when`. */ + val APP_HANDLED_SERVER_REQUESTS = + setOf("approval", "clarify", "sudo", "secret", "vault.code", "vault.save_login", "vault.unlock_prompt") + + /** + * Union of literal param keys at production call sites (the two client-owned + * ones, prompt.submit and session.events.since, are checked against real frames + * in [GatewayContractFramesTest]). Update together with the call site. + */ + val CALL_SITE_PARAM_KEYS: Map> = + mapOf( + // ChatViewModel, GroupChatViewModel + "session.create" to setOf("profile", "title", "source", "hidden"), + // ChatViewModel.resumeSession, NotificationReplyReceiver + "session.resume" to setOf("session_id", "omit_messages"), + // ChatViewModel, GroupChatViewModel + "session.interrupt" to setOf("session_id"), + ) + } +} diff --git a/app/src/test/resources/gateway-contract/SOURCE b/app/src/test/resources/gateway-contract/SOURCE new file mode 100644 index 00000000..4b0fb58e --- /dev/null +++ b/app/src/test/resources/gateway-contract/SOURCE @@ -0,0 +1,5 @@ +repo: NousResearch/hermes-agent +ref: main +sha: ea114c3e98c3339e13004adfc6098cf28ed7d754 +date: 2026-09-29 +path: apps/shared/src/gateway-contract.openrpc.json diff --git a/app/src/test/resources/gateway-contract/openrpc.json b/app/src/test/resources/gateway-contract/openrpc.json new file mode 100644 index 00000000..7a63156f --- /dev/null +++ b/app/src/test/resources/gateway-contract/openrpc.json @@ -0,0 +1,40113 @@ +{ + "openrpc": "1.3.2", + "info": { + "title": "Hermes TUI/Desktop gateway", + "version": "1", + "description": "Generated from tui_gateway/contracts by scripts/gen_gateway_contracts.py." + }, + "methods": [ + { + "name": "agents.list", + "summary": "Registry-wide background process summary for ``/agents``.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/AgentsListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/AgentsListResult" + } + } + }, + { + "name": "approval.pending", + "summary": "Replay the approvals still waiting on this session (reconnect / polling).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ApprovalPendingParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ApprovalPendingResult" + } + } + }, + { + "name": "approval.received", + "summary": "Tell the backend the card is on screen, so its timeout clock starts.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ApprovalReceivedParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ApprovalReceivedResult" + } + } + }, + { + "name": "approval.respond", + "summary": "Deliver the user's decision on a dangerous command (falls back to durable identity on a stale sid).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ApprovalRespondParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ApprovalRespondResult" + } + } + }, + { + "name": "billing.auto_reload", + "summary": "Enable/disable auto top-up with its threshold and reload amount (billing:manage).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BillingAutoReloadParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BillingMutationResult" + } + } + }, + { + "name": "billing.charge", + "summary": "Start a one-off top-up charge (billing:manage, idempotent).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BillingChargeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BillingChargeResult" + } + } + }, + { + "name": "billing.charge_status", + "summary": "Poll one charge by id.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BillingChargeStatusParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BillingChargeStatusResult" + } + } + }, + { + "name": "billing.state", + "summary": "Read-only billing view (no scope); the Nous free tier is answered locally without a portal call.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BillingStateResult" + } + } + }, + { + "name": "billing.step_up", + "summary": "Run the billing:manage device flow; the URL/code arrive via billing.step_up.verification.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BillingStepUpParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BillingStepUpResult" + } + } + }, + { + "name": "bot_relay.deliver", + "summary": "Deliver a relayed DM into a Bot Chat on this gateway and return the one-turn reply (blocking).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BotRelayDeliverParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BotRelayDeliverResult" + } + } + }, + { + "name": "bot_relay.outbox.drain", + "summary": "Atomically claim every pending cross-connection envelope queued on this gateway.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BotRelayOutboxDrainParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BotRelayOutboxDrainResult" + } + } + }, + { + "name": "bot_relay.reply", + "summary": "Write a relayed reply and/or typed error for an envelope so the sender-side waiter resolves.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BotRelayReplyParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OkResult" + } + } + }, + { + "name": "bot_relay.roster.sync", + "summary": "Replace this gateway's view of agents on other connections; answers the accepted row count.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BotRelayRosterSyncParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BotRelayRosterSyncResult" + } + } + }, + { + "name": "browser.controller.detach", + "summary": "Hard-detach only the controller owned by this authenticated transport.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BrowserControllerParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BrowserControllerDetachResult" + } + } + }, + { + "name": "browser.controller.heartbeat", + "summary": "Acknowledge a heartbeat only for this transport's own attached controller.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BrowserControllerParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OkResult" + } + } + }, + { + "name": "browser.controller.register", + "summary": "Attach this connection as the browser controller for one session; fails closed (4403).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BrowserControllerRegisterParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BrowserControllerRegisterResult" + } + } + }, + { + "name": "browser.controller.result", + "summary": "Deliver one command result to the broker; accepted is false for unknown or settled command ids.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BrowserControllerResultParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BrowserControllerResultResult" + } + } + }, + { + "name": "browser.manage", + "summary": "Inspect, attach to, or drop the CDP browser the tools use; ``messages`` narrate a connect.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BrowserManageParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BrowserManageResult" + } + } + }, + { + "name": "clarify.lock", + "summary": "Lock one answer of a batch clarify request (editable until every question is locked).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ClarifyLockParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ClarifyLockResult" + } + } + }, + { + "name": "cli.exec", + "summary": "Run ``hermes `` non-interactively and capture its output; ``blocked`` explains a refusal.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/CliExecParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CliExecResult" + } + } + }, + { + "name": "client.capabilities", + "summary": "What the calling client handles, sent once per connection (after gateway.ready); returns the server\u2192client request methods this backend may send.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ClientCapabilitiesParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ClientCapabilitiesResult" + } + } + }, + { + "name": "clipboard.paste", + "summary": "Save the host clipboard image into the session and queue it for the next turn.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ClipboardPasteParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/AttachedImageResult" + } + } + }, + { + "name": "command.dispatch", + "summary": "Run a quick/plugin/bundle/skill/built-in slash command and answer a structured directive.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/CommandDispatchParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CommandDispatchResult" + } + } + }, + { + "name": "command.resolve", + "summary": "Canonical registry command for a name or alias.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/CommandResolveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CommandResolveResult" + } + } + }, + { + "name": "commands.catalog", + "summary": "Categorized slash metadata (registry, quick, plugin, skill) for completion menus.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/CommandsCatalogParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CommandsCatalogResult" + } + } + }, + { + "name": "complete.path", + "summary": "Path / @-reference completions for the composer (files, folders, profiles, plugin providers).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/CompletePathParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CompletionItemsResult" + } + } + }, + { + "name": "complete.slash", + "summary": "Ranked slash-command / skill completions for a ``/`` token.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/CompleteSlashParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CompleteSlashResult" + } + } + }, + { + "name": "config.get", + "summary": "Read one normalised config value (or the whole effective config) the way the UIs render it.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConfigGetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConfigGetResult" + } + } + }, + { + "name": "config.set", + "summary": "Change one config key (persisted or session-scoped) and read back the normalised value.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConfigSetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConfigSetResult" + } + } + }, + { + "name": "config.show", + "summary": "Masked, display-ready config summary (model / agent / environment rows).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConfigShowParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConfigShowResult" + } + } + }, + { + "name": "connection.respond", + "summary": "Per-target outcomes from the card, and an optional Continue.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectionRespondParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectionRespondResult" + } + } + }, + { + "name": "connectors.accounts", + "summary": "The scoped member's hosted connector accounts, optionally filtered by connector slug.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectorAccountsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorAccountsResult" + } + } + }, + { + "name": "connectors.accounts.remove", + "summary": "Remove one hosted connector account owned by the scoped member.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectorAccountsRemoveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorAccountsRemoveResult" + } + } + }, + { + "name": "connectors.catalog", + "summary": "The hosted connector catalog available to the scoped member.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorsCatalogResult" + } + } + }, + { + "name": "connectors.connect", + "summary": "Start or re-initiate authorization for named connectors on a session or account operation.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectorsConnectParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorsConnectResult" + } + } + }, + { + "name": "connectors.list", + "summary": "Connector catalog + connection state for one session or profile account owner.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectorsListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorsListResult" + } + } + }, + { + "name": "connectors.operation.status", + "summary": "The current snapshot of one open session or account operation.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectionOperationParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectionOperationStatus" + } + } + }, + { + "name": "connectors.operation.wake", + "summary": "The browser leg came back (hermes://connections/done): read the accounts now, not at the next tick.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectionOperationParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectionWakeResult" + } + } + }, + { + "name": "connectors.policy.get", + "summary": "Policy layers for the scoped member, from organization to member scope.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorPolicyGetResult" + } + } + }, + { + "name": "connectors.policy.set", + "summary": "Apply one scoped member connector or tool-list policy change.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectorPolicySetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorPolicySetResult" + } + } + }, + { + "name": "connectors.tools", + "summary": "The scoped profile's cached or current tool list for one connector.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectorToolsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorToolsResult" + } + } + }, + { + "name": "cron.manage", + "summary": "List/add/remove/pause/resume cron jobs in the (optionally profile-scoped) cron store.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/CronManageParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CronManageResult" + } + } + }, + { + "name": "delegation.pause", + "summary": "Block/unblock NEW spawns globally (active children keep running); returns the new state.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DelegationPauseParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DelegationPauseResult" + } + } + }, + { + "name": "delegation.status", + "summary": "Running subagent tree plus the spawn pause flag and limits.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DelegationStatusResult" + } + } + }, + { + "name": "diagnostics.share_nous", + "summary": "Upload a force-redacted debug bundle to Nous-internal diagnostics storage.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DiagnosticsShareNousParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DiagnosticsShareNousResult" + } + } + }, + { + "name": "display.install", + "summary": "Run the distro package install on the gateway host; progress streams as display.install.log/.done.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayInstallResult" + } + } + }, + { + "name": "display.lease.acquire", + "summary": "Take over: the human named by a viewer id this connection minted controls the screen.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DisplayLeaseAcquireParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayLeaseResult" + } + } + }, + { + "name": "display.lease.release", + "summary": "Hand back. Without a viewer id the release is refused while a human holds unless force.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DisplayLeaseReleaseParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayLeaseResult" + } + } + }, + { + "name": "display.observe", + "summary": "Mint a single-use ticket for /api/display/ws and the server-minted viewer id for this connection.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DisplayObserveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayObserveResult" + } + } + }, + { + "name": "display.start", + "summary": "Start this profile's Xvnc + Xfce (idempotent); blocks until the display is published.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayStatus" + } + } + }, + { + "name": "display.status", + "summary": "Runtime + lease snapshot for this profile's screen.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayStatus" + } + } + }, + { + "name": "display.stop", + "summary": "Stop the screen. Refused (5300, code viewer_mismatch) while a human holds unless force.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DisplayStopParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayStopResult" + } + } + }, + { + "name": "display.switchSandboxImage", + "summary": "Decide the pending default sandbox image switch for this profile; refused when none is pending.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DisplaySwitchSandboxImageParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplaySwitchSandboxImageResult" + } + } + }, + { + "name": "display.thumbnail", + "summary": "One JPEG grab of the bot's screen; read-only, never changes the lease.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayThumbnailResult" + } + } + }, + { + "name": "file.attach", + "summary": "Stage a non-image file into the session workspace and hand back its @file: ref.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/FileAttachParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/FileAttachResult" + } + } + }, + { + "name": "free_tier.ack_notice", + "summary": "Mark the one-time availability notice as shown on the free-tier identity.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/FreeTierAckNoticeResult" + } + } + }, + { + "name": "free_tier.provision", + "summary": "Explicit retry of the free-tier identity mint when the boot bootstrap could not create it.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/FreeTierProvisionResult" + } + } + }, + { + "name": "free_tier.status", + "summary": "Pure read of the focused profile's free-tier identity state (no network, no side effects).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/FreeTierStatusResult" + } + } + }, + { + "name": "gateway.capabilities", + "summary": "What THIS build enforces (a client withholds a feature unless advertised).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PingParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GatewayCapabilitiesResult" + } + } + }, + { + "name": "groups.approve", + "summary": "Resolve one exact pending approval raised by a local or peer room member.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsApproveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsApproveResult" + } + } + }, + { + "name": "groups.capabilities", + "summary": "Describe the hosted-room protocol implemented by this gateway.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsCapabilitiesParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsCapabilitiesResult" + } + } + }, + { + "name": "groups.create", + "summary": "Create a hosted room idempotently; authority is this gateway's stable install identity.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsCreateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsCreateResult" + } + } + }, + { + "name": "groups.demote", + "summary": "Fence this gateway's stale room authority against a proven newer epoch.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsDemoteParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsDemoteResult" + } + } + }, + { + "name": "groups.disband", + "summary": "Permanently tombstone a hosted room id after stopping its work and revoking peer routes.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsDisbandParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsDisbandResult" + } + } + }, + { + "name": "groups.list", + "summary": "List rooms hosted by this gateway, most recently changed first.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsListResult" + } + } + }, + { + "name": "groups.log", + "summary": "A monotonic room-log delta after since_seq, bounded by count and page bytes.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsLogParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsLogResult" + } + } + }, + { + "name": "groups.peer.invite", + "summary": "Mint one target-issued room/profile grant for a prospective room home.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsPeerInviteParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsPeerInviteResult" + } + } + }, + { + "name": "groups.peer.register", + "summary": "Register and probe one scoped peer route on the room home.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsPeerRegisterParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsPeerRegisterResult" + } + } + }, + { + "name": "groups.peer.revoke", + "summary": "Revoke one target-issued grant using its exact profile scope.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsPeerRevokeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsPeerRevokeResult" + } + } + }, + { + "name": "groups.promote", + "summary": "Continue a replicated room on this gateway at epoch + 1; requires confirm=true.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsPromoteParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsPromoteResult" + } + } + }, + { + "name": "groups.rename", + "summary": "Rename one hosted room atomically with its replay event.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsRenameParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsRenameResult" + } + } + }, + { + "name": "groups.replica_state", + "summary": "The local replica's coverage and authority lineage for one room.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsReplicaStateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsReplicaStateResult" + } + } + }, + { + "name": "groups.replicate", + "summary": "Persist one authority-stamped replay page into the local replica store; idempotent.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsReplicateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsReplicateResult" + } + } + }, + { + "name": "groups.retry", + "summary": "Retry one indeterminate room task after explicit user confirmation.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsRetryParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsRetryResult" + } + } + }, + { + "name": "groups.send", + "summary": "Append one inert message.user event idempotently; the actor is server-owned.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsSendParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsSendResult" + } + } + }, + { + "name": "groups.state", + "summary": "One hosted room's replay cursor and fenced authority state, plus live driver status.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsStateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsStateResult" + } + } + }, + { + "name": "groups.stop", + "summary": "Durably cancel queued or running work for one hosted room.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsStopParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsStopResult" + } + } + }, + { + "name": "handoff.fail", + "summary": "Fail a not-yet-claimed handoff (client poll timeout); CAS against the watcher.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/HandoffFailParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/HandoffFailResult" + } + } + }, + { + "name": "handoff.request", + "summary": "Queue a handoff to a messaging platform's home channel; the gateway watcher claims it.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/HandoffRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/HandoffRequestResult" + } + } + }, + { + "name": "handoff.state", + "summary": "Poll the handoff row for this session.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/HandoffStateResult" + } + } + }, + { + "name": "i18n.catalog", + "summary": "Pack + overlay messages for one language and surface; the renderer merges them over its bundled catalog.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/I18nCatalogParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/I18nCatalogResult" + } + } + }, + { + "name": "i18n.languages", + "summary": "Every language some layer supplies (bundled \u222a user overlay \u222a plugin packs), en first.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/I18nLanguagesResult" + } + } + }, + { + "name": "image.attach", + "summary": "Queue a gateway-visible image file for the next turn.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ImageAttachParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/AttachedImageResult" + } + } + }, + { + "name": "image.attach_bytes", + "summary": "Queue an image uploaded as base64 (remote client); reply mirrors image.attach.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ImageAttachBytesParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/AttachedImageResult" + } + } + }, + { + "name": "image.detach", + "summary": "Drop a queued image before the turn is sent.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ImageDetachParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ImageDetachResult" + } + } + }, + { + "name": "image.generate", + "summary": "Generate an image through the tool's provider dispatcher and hand the renderer a data URL.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ImageGenerateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ImageGenerateResult" + } + } + }, + { + "name": "input.detect_drop", + "summary": "Recognise a terminal file drop pasted into the composer and turn it into an attachment.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/InputDetectDropParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/InputDetectDropResult" + } + } + }, + { + "name": "insights.get", + "summary": "Session/message counts over the last ``days`` for the (optionally scoped) profile store.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/InsightsGetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/InsightsGetResult" + } + } + }, + { + "name": "learning.delete", + "summary": "Archive a skill (restorable via curator) or remove a memory chunk.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/LearningNodeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/LearningMutationResult" + } + } + }, + { + "name": "learning.detail", + "summary": "Node content (SKILL.md or memory chunk) for an edit prefill.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/LearningNodeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/LearningDetailResult" + } + } + }, + { + "name": "learning.edit", + "summary": "Rewrite a node's content (SKILL.md or memory chunk).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/LearningEditParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/LearningMutationResult" + } + } + }, + { + "name": "learning.frames", + "summary": "Pre-render the /journey timeline (frames + legend/summary) so the TUI walks it locally.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/LearningFramesParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/LearningFramesResult" + } + } + }, + { + "name": "llm.oneshot", + "summary": "Stateless one-shot LLM completion (titles, ideas) on the session's or the task backend.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/LlmOneshotParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/LlmOneshotResult" + } + } + }, + { + "name": "mcp.catalog", + "summary": "Curated MCP presets with per-profile installed/enabled state and the env keys each needs.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpCatalogResult" + } + } + }, + { + "name": "mcp.servers.add", + "summary": "Add a server to the profile's config from a catalog preset and/or an explicit config.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpServersAddParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpServersAddResult" + } + } + }, + { + "name": "mcp.servers.list", + "summary": "Configured MCP servers for the (scoped) profile, secrets redacted to env-key names.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpServersListResult" + } + } + }, + { + "name": "mcp.servers.oauth.callback", + "summary": "Relay a client-captured redirect into a client_redirect_uri flow.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpOauthCallbackParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpOauthCallbackResult" + } + } + }, + { + "name": "mcp.servers.oauth.cancel", + "summary": "Cancel a flow owned by the resolved profile, waking its callback worker.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpOauthFlowParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpOauthCancelResult" + } + } + }, + { + "name": "mcp.servers.oauth.poll", + "summary": "Poll a flow; approved persists tokens for the profile and returns the probed tools.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpOauthFlowParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpOauthPollResult" + } + } + }, + { + "name": "mcp.servers.oauth.start", + "summary": "Begin a PKCE OAuth flow; the client opens auth_url and polls mcp.servers.oauth.poll.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpOauthStartParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpOauthStartResult" + } + } + }, + { + "name": "mcp.servers.remove", + "summary": "Drop a server from the profile's config.yaml.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpServerNameParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpServersRemoveResult" + } + } + }, + { + "name": "mcp.servers.set_api_key", + "summary": "Store a credential in the profile's .env and reference it from the server config (header or env).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpServersSetApiKeyParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpServersSetApiKeyResult" + } + } + }, + { + "name": "mcp.servers.status", + "summary": "Cached runtime state per configured server; never connects, probes, or starts auth.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpServersStatusResult" + } + } + }, + { + "name": "mcp.servers.test", + "summary": "Connect, list tools, disconnect \u2014 an OAuth server with no token on disk is reported as not ok.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpServerNameParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpServersTestResult" + } + } + }, + { + "name": "message.react", + "summary": "Set/clear one author's emoji reaction on a message; returns the row's full reaction list.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/MessageReactParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/MessageReactResult" + } + } + }, + { + "name": "model.disconnect", + "summary": "Remove every credential (env keys and OAuth state) for a provider.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ModelDisconnectParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ModelDisconnectResult" + } + } + }, + { + "name": "model.options", + "summary": "Provider/model inventory for the picker, layered over the session's live provider when given.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ModelOptionsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ModelOptionsResult" + } + } + }, + { + "name": "model.save_key", + "summary": "Save an API key for a provider and return its refreshed inventory row.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ModelSaveKeyParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ModelSaveKeyResult" + } + } + }, + { + "name": "onboarding.ensure_setup_profile", + "summary": "Create-or-read the backend-owned setup profile; the backend picks the name and finds it by role.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/Params" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OnboardingEnsureSetupProfileResult" + } + } + }, + { + "name": "onboarding.reset_setup_profile", + "summary": "Restore the setup profile to its created state in place (soul, memories, skills, sessions).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/Params" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OnboardingResetSetupProfileResult" + } + } + }, + { + "name": "paste.collapse", + "summary": "Spill a large paste to a file and hand back the inline placeholder.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PasteCollapseParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PasteCollapseResult" + } + } + }, + { + "name": "pdf.attach", + "summary": "Render a PDF's pages to PNG and queue them as images for the next turn.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PdfAttachParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PdfAttachResult" + } + } + }, + { + "name": "pet.cancel", + "summary": "Stop an in-flight pet generate/hatch by token (idempotent).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetCancelParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetCancelResult" + } + } + }, + { + "name": "pet.cells", + "summary": "Half-block cell frames (or a kitty placement) for one pet state.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetCellsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetCellsResult" + } + } + }, + { + "name": "pet.disable", + "summary": "Turn the pet display off from the desktop picker.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OkResult" + } + } + }, + { + "name": "pet.export", + "summary": "Export an installed pet as a re-importable .zip.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetSlugParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetExportResult" + } + } + }, + { + "name": "pet.gallery", + "summary": "Petdex gallery + local install state (installed-only offline); localOnly skips the remote manifest.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetGalleryParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetGalleryResult" + } + } + }, + { + "name": "pet.generate", + "summary": "Candidate base looks for a new pet (draft step); drafts also stream via pet.generate.progress.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetGenerateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetGenerateResult" + } + } + }, + { + "name": "pet.generate.status", + "summary": "Whether pet generation is possible (a reference-capable image backend) and which providers.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetGenerateStatusResult" + } + } + }, + { + "name": "pet.hatch", + "summary": "Turn a base draft into a full spritesheet pet; progress streams via pet.hatch.progress.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetHatchParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetHatchResult" + } + } + }, + { + "name": "pet.info", + "summary": "Active pet for sprite renderers: spritesheet (base64) + frame geometry + state-row taxonomy.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetInfoParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetInfoResult" + } + } + }, + { + "name": "pet.info.meta", + "summary": "Cheap active-pet metadata used to avoid full payload refreshes.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetInfoMetaResult" + } + } + }, + { + "name": "pet.remove", + "summary": "Uninstall a pet (delete its directory); if it was active, turn the display off.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetSlugParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetSlugResult" + } + } + }, + { + "name": "pet.rename", + "summary": "Rename a pet's display name + realign its slug/dir; follows the active slug in config.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetRenameParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetSlugResult" + } + } + }, + { + "name": "pet.scale", + "summary": "Persist display.pet.scale (clamped to engine bounds) from the desktop slider.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetScaleParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetScaleResult" + } + } + }, + { + "name": "pet.select", + "summary": "Adopt a pet: install (if needed) + activate; writes display.pet.* to config.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetSlugParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetSlugResult" + } + } + }, + { + "name": "pet.thumb", + "summary": "Idle-frame PNG data URI for the picker (desktop CSP breaks CDN ).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetThumbParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetThumbResult" + } + } + }, + { + "name": "ping", + "summary": "Cheapest liveness probe; answered on the WS reader thread even while every agent is mid-turn.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PingParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PingResult" + } + } + }, + { + "name": "plugins.list", + "summary": "Loaded plugin manager entries (legacy flat view); the Plugins Hub uses plugins.manage list.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PluginsListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PluginsListResult" + } + } + }, + { + "name": "plugins.manage", + "summary": "Plugins Hub backend: list installed plugins, toggle, git-install, re-pin a catalog install, or remove a user install.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PluginsManageParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PluginsManageResult" + } + } + }, + { + "name": "preview.restart", + "summary": "Spawn a hidden agent that brings the desktop preview's dev server back up.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PreviewRestartParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/TaskIdResult" + } + } + }, + { + "name": "process.kill", + "summary": "Kill one background process the caller's session owns and return its output snapshot.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProcessKillParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProcessKillResult" + } + } + }, + { + "name": "process.list", + "summary": "Background processes owned by the caller's session (desktop status stack poll).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProcessListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProcessListResult" + } + } + }, + { + "name": "process.stop", + "summary": "Kill every background process in the registry (``/stop``), answering the count killed.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProcessStopParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProcessStopResult" + } + } + }, + { + "name": "profiles.configure", + "summary": "Editor Save: apply any subset of a profile's sections and report each one.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfilesConfigureParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProfilesConfigureResult" + } + } + }, + { + "name": "profiles.create", + "summary": "Create a profile (ws twin of POST /api/profiles), mirroring launch credentials by default.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfilesCreateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProfilesCreateResult" + } + } + }, + { + "name": "profiles.describe", + "summary": "Everything the profile editor shows: soul, model pin, skills, toolsets, MCP servers.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileNameParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProfilesDescribeResult" + } + } + }, + { + "name": "profiles.get_asset", + "summary": "A profile asset as a data URL.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfilesGetAssetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProfilesGetAssetResult" + } + } + }, + { + "name": "profiles.list", + "summary": "Roster of profiles with previews so a client paints without N follow-up calls.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfilesListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProfilesListResult" + } + } + }, + { + "name": "profiles.remember_onboarding", + "summary": "Write the onboarding facts into the default profile's user memory and confirm they landed.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfilesRememberOnboardingParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProfilesRememberOnboardingResult" + } + } + }, + { + "name": "profiles.set_asset", + "summary": "Store or clear a profile asset (avatar) atomically.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfilesSetAssetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProfilesSetAssetResult" + } + } + }, + { + "name": "project.facts", + "summary": "Structured project facts for a cwd so UIs don't re-sniff the workspace.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectFactsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectFactsResult" + } + } + }, + { + "name": "projects.add_folder", + "summary": "Attach a folder to a project (optionally as its primary path).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsAddFolderParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectResult" + } + } + }, + { + "name": "projects.archive", + "summary": "Archive (or with ``restore`` un-archive) a project; answers the full listing.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsArchiveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsPayload" + } + } + }, + { + "name": "projects.create", + "summary": "Create a project from a name + folders; duplicate primary paths are refused (5063).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsCreateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OptionalProjectResult" + } + } + }, + { + "name": "projects.delete", + "summary": "Delete a project and its folders; answers the full listing.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectIdParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsPayload" + } + } + }, + { + "name": "projects.discover_repos", + "summary": "Repos for the desktop overview: scanned-from-disk (cached) \u222a session-derived.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsDiscoverReposParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsDiscoverReposResult" + } + } + }, + { + "name": "projects.for_cwd", + "summary": "Which project (if any) owns a directory, plus the resolved cwd and its git branch.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsForCwdParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsForCwdResult" + } + } + }, + { + "name": "projects.get", + "summary": "One stored project with its folders.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectIdParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectResult" + } + } + }, + { + "name": "projects.list", + "summary": "Every project of the profile (archived included) plus which one is active.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsPayload" + } + } + }, + { + "name": "projects.project_sessions", + "summary": "Fully hydrated lanes for one project, from the same grouping as projects.tree.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsProjectSessionsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsProjectSessionsResult" + } + } + }, + { + "name": "projects.record_repos", + "summary": "Persist repo roots found by the client's (desktop-side) scan; return the merged list.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsRecordReposParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsRecordReposResult" + } + } + }, + { + "name": "projects.remove_folder", + "summary": "Detach a folder from a project.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectFolderParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectResult" + } + } + }, + { + "name": "projects.set_active", + "summary": "Switch (or clear) the active project for the profile.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsSetActiveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ActiveIdResult" + } + } + }, + { + "name": "projects.set_primary", + "summary": "Make one attached folder the project's primary path.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectFolderParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectResult" + } + } + }, + { + "name": "projects.tree", + "summary": "Project \u2192 repo \u2192 lane overview with counts and a few preview sessions per project.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsTreeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsTreeResult" + } + } + }, + { + "name": "projects.update", + "summary": "Patch a project's display fields; answers the refreshed project.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsUpdateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectResult" + } + } + }, + { + "name": "prompt.background", + "summary": "Run a task on a fresh agent in the background; the answer arrives as background.complete.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SideAgentParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/TaskIdResult" + } + } + }, + { + "name": "prompt.btw", + "summary": "Side question over a snapshot of the live conversation; the answer arrives as btw.complete.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SideAgentParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/TaskIdResult" + } + } + }, + { + "name": "prompt.submit", + "summary": "Send a user turn to a live session; busy sessions queue / steer / redirect instead of refusing.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PromptSubmitParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PromptSubmitResult" + } + } + }, + { + "name": "reload.env", + "summary": "Re-read ~/.hermes/.env (CLI /reload parity); built agents keep their pool until /new.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ReloadEnvParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ReloadEnvResult" + } + } + }, + { + "name": "reload.mcp", + "summary": "Tear down and rediscover MCP servers for every live session (prompt cache is invalidated).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ReloadMcpParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ReloadMcpResult" + } + } + }, + { + "name": "request.answer", + "summary": "Answer an open server\u2192client request from a client that never received the frame.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/RequestAnswerParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/RequestAnswerResult" + } + } + }, + { + "name": "rollback.diff", + "summary": "Diff between a checkpoint and the working tree, with an ANSI rendering sized to the TUI.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/RollbackDiffParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/RollbackDiffResult" + } + } + }, + { + "name": "rollback.list", + "summary": "Checkpoints for the session's cwd; ``enabled: false`` when checkpointing is off.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/RollbackListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/RollbackListResult" + } + } + }, + { + "name": "rollback.restore", + "summary": "Restore the working tree (or one file) to a checkpoint by hash or 1-based index.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/RollbackRestoreParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/RollbackRestoreResult" + } + } + }, + { + "name": "session.activate", + "summary": "Attach the frontend to a live session without closing the previously focused one.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionActivateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionActivateResult" + } + } + }, + { + "name": "session.active_list", + "summary": "Live sessions in this process, insertion order (not a DB browser).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionActiveListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionActiveListResult" + } + } + }, + { + "name": "session.archive", + "summary": "Set/clear archived (soft-hide, messages kept) on a session + lineage; Desktop PATCH parity.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionArchiveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionArchiveResult" + } + } + }, + { + "name": "session.branch", + "summary": "Fork a live session into a new stored child that shares the parent's history so far.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionBranchParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionBranchResult" + } + } + }, + { + "name": "session.branch_stored", + "summary": "Whole-session branch of a stored parent: the owning backend reads and copies the transcript, which never crosses the wire (a separate method so an older gateway fails loudly, not with an empty branch).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionBranchStoredParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionBranchStoredResult" + } + } + }, + { + "name": "session.branch_whole", + "summary": "session.branch of the whole history without echoing the copied transcript back.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionBranchWholeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionBranchWholeResult" + } + } + }, + { + "name": "session.close", + "summary": "Tear down a live session (its stored row stays resumable).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionCloseParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionCloseResult" + } + } + }, + { + "name": "session.compress", + "summary": "Manual /compress of an idle session, optionally focused on a topic.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionCompressParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionCompressResult" + } + } + }, + { + "name": "session.context_breakdown", + "summary": "Cursor-style split of the context window by category.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionContextBreakdownParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionContextBreakdownResult" + } + } + }, + { + "name": "session.control", + "summary": "Run one allowlisted goal / loop / subgoal / heartbeat action and return the exact resulting snapshot.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionControlParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionControlResult" + } + } + }, + { + "name": "session.control.read", + "summary": "Stable, allowlisted snapshot of one live session's goal / loop / heartbeat state.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionControlReadParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionControlReadResult" + } + } + }, + { + "name": "session.create", + "summary": "Mint a live session (agent builds after the reply); a DB row appears on the first prompt unless seeded.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionCreateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionCreateResult" + } + } + }, + { + "name": "session.cwd.set", + "summary": "Change a live, idle session's working directory.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionCwdSetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionCwdSetResult" + } + } + }, + { + "name": "session.delete", + "summary": "Delete a stored session + transcripts; refused while it is live here.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionDeleteParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionDeleteResult" + } + } + }, + { + "name": "session.events.since", + "summary": "Replay events after a seq watermark on WS reconnect; truncated means refetch state.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionEventsSinceParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionEventsSinceResult" + } + } + }, + { + "name": "session.events.stats", + "summary": "Replay-buffer occupancy telemetry (ops/debug).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionEventsStatsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionEventsStatsResult" + } + } + }, + { + "name": "session.foreign.import", + "summary": "Import a foreign session into this profile's history (idempotent per origin).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionForeignIdParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionForeignImportResult" + } + } + }, + { + "name": "session.foreign.list", + "summary": "One page of Claude Code / Codex sessions found on the serving backend.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionForeignListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionForeignListResult" + } + } + }, + { + "name": "session.foreign.preview", + "summary": "Preview a foreign session's tail before importing it.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionForeignIdParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionForeignPreviewResult" + } + } + }, + { + "name": "session.history", + "summary": "The durable display transcript (ancestors included, row ids attached).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionHistoryParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionHistoryResult" + } + } + }, + { + "name": "session.interrupt", + "summary": "Stop the running turn (and streaming TTS); retires the crash-recovery marker.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionInterruptParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionInterruptResult" + } + } + }, + { + "name": "session.list", + "summary": "Human-facing stored sessions, most recent first (sub-agent / kanban sources denied).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionListResult" + } + } + }, + { + "name": "session.most_recent", + "summary": "Most recent human-facing session; errors fold into a null session_id.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionMostRecentParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionMostRecentResult" + } + } + }, + { + "name": "session.redirect", + "summary": "Redirect the active turn (queued for the next turn while the agent is still building).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionCorrectionParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionCorrectionResult" + } + } + }, + { + "name": "session.resume", + "summary": "Attach to a stored session: reuse it if live here, else lazy / deferred / cold / eager rebuild.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionResumeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionResumeResult" + } + } + }, + { + "name": "session.save", + "summary": "Export the transcript to ~/.hermes/sessions/saved (classic /save).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionSaveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionSaveResult" + } + } + }, + { + "name": "session.set_hidden", + "summary": "Set/clear hidden (out of the default list, still resumable by its owner) on a session + lineage.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionSetHiddenParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionSetHiddenResult" + } + } + }, + { + "name": "session.status", + "summary": "Rendered /status text for the session.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionStatusParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionStatusResult" + } + } + }, + { + "name": "session.steer", + "summary": "Inject text into the next tool result without interrupting the turn.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionCorrectionParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionCorrectionResult" + } + } + }, + { + "name": "session.title", + "summary": "Read or set a live session's title; a title set before the row exists is queued.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionTitleParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionTitleResult" + } + } + }, + { + "name": "session.undo", + "summary": "Drop the last user turn (and everything after it) from an idle session.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionUndoParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionUndoResult" + } + } + }, + { + "name": "session.usage", + "summary": "Token / context / cost counters for the session (+ Nous credit lines when available).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionUsageParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionUsageResult" + } + } + }, + { + "name": "session.workspace.move", + "summary": "Re-home a stored session's workspace; git identity is replaced and a live agent follows.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionWorkspaceMoveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionWorkspaceMoveResult" + } + } + }, + { + "name": "setup.runtime_check", + "summary": "Strict provider check through the same runtime resolution the agent uses on session creation.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SetupRuntimeCheckParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SetupRuntimeCheckResult" + } + } + }, + { + "name": "setup.status", + "summary": "Loose provider check: is ANY provider auth state discoverable for the (launch or named) profile.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SetupStatusResult" + } + } + }, + { + "name": "shared_metrics.desktop_daily", + "summary": "Record one finished Desktop day (mode use + button presses); recorded=false keeps it for a retry.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsDesktopDailyParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedMetricsDesktopDailyResult" + } + } + }, + { + "name": "shared_metrics.desktop_dislike", + "summary": "Count one Desktop dislike signal (fire-and-forget; capped per signal per day; a no-op unless on).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsDesktopDislikeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OkResult" + } + } + }, + { + "name": "shared_metrics.desktop_feature_use", + "summary": "Count one Desktop area used today (fire-and-forget; once per area per UTC day; a no-op unless on).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsDesktopFeatureUseParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OkResult" + } + } + }, + { + "name": "shared_metrics.desktop_friction", + "summary": "Count one Desktop friction event (fire-and-forget; capped per day; a no-op unless on).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsDesktopFrictionParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OkResult" + } + } + }, + { + "name": "shared_metrics.desktop_onboarding", + "summary": "Count one Desktop first-run step transition (fire-and-forget; once per step+event; a no-op unless on).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsDesktopOnboardingParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OkResult" + } + } + }, + { + "name": "shared_metrics.set", + "summary": "Write both shared-metrics opt-ins at once (send requires collection) and reconcile consent windows.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsSetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedMetricsConsentResult" + } + } + }, + { + "name": "shared_metrics.slash_command", + "summary": "Count one user-typed slash command (fire-and-forget; a no-op unless shared metrics are on).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsSlashCommandParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedMetricsSlashCommandResult" + } + } + }, + { + "name": "shared_metrics.startup_latency", + "summary": "Record one client launch-to-ready latency (fire-and-forget; a no-op unless shared metrics are on).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsStartupLatencyParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedMetricsStartupLatencyResult" + } + } + }, + { + "name": "shared_metrics.status", + "summary": "Pure read of the focused profile's shared-metrics opt-ins (collection, upload, answered).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedMetricsConsentResult" + } + } + }, + { + "name": "shared_metrics.update_run", + "summary": "Count one Desktop packaged self-update outcome (fire-and-forget; a no-op unless shared metrics are on).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsUpdateRunParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedMetricsUpdateRunResult" + } + } + }, + { + "name": "shell.exec", + "summary": "Run a safe (non-dangerous) shell command captured for ``!cmd`` / inline substitution.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ShellExecParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ShellExecResult" + } + } + }, + { + "name": "skills.manage", + "summary": "Skills hub backend: list the profile's skills or search / browse / inspect / install from the hub.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SkillsManageParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SkillsManageResult" + } + } + }, + { + "name": "skills.reload", + "summary": "Re-scan skill dirs; the pre-rendered ``output`` is what /reload-skills prints.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SkillsReloadParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SkillsReloadResult" + } + } + }, + { + "name": "slash.exec", + "summary": "Execute a slash command against the session's slash worker (or a live/plugin shortcut).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SlashExecParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SlashExecResult" + } + } + }, + { + "name": "spawn_tree.list", + "summary": "Saved spawn-tree snapshots, newest first.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SpawnTreeListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SpawnTreeListResult" + } + } + }, + { + "name": "spawn_tree.load", + "summary": "Read one saved spawn-tree snapshot (path must be under the spawn-trees root).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SpawnTreeLoadParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SpawnTreeLoadResult" + } + } + }, + { + "name": "spawn_tree.save", + "summary": "Persist a finished delegation tree snapshot under the session's spawn-trees dir.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SpawnTreeSaveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SpawnTreeSaveResult" + } + } + }, + { + "name": "subagent.interrupt", + "summary": "Hard-interrupt one owned child; ``found`` is false when it already finished.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SubagentIdParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SubagentInterruptResult" + } + } + }, + { + "name": "subagent.list", + "summary": "Live children owned by this session (other sessions' children never leak).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SubagentListResult" + } + } + }, + { + "name": "subagent.steer", + "summary": "Queue steering text into a live delegated child owned by this session.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SubagentSteerParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SubagentSteerResult" + } + } + }, + { + "name": "subagent.tail", + "summary": "Last 16KB of an owned child's live transcript.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SubagentIdParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SubagentTailResult" + } + } + }, + { + "name": "subscription.change", + "summary": "Schedule a downgrade / same-price change or a period-end cancellation.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SubscriptionChangeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BillingPendingChangeResult" + } + } + }, + { + "name": "subscription.preview", + "summary": "Chargeless quote of what a plan change would do (billing:manage).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SubscriptionPreviewParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SubscriptionPreviewResult" + } + } + }, + { + "name": "subscription.resume", + "summary": "Clear a scheduled downgrade / cancellation (re-enables recurring spend).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BillingPendingChangeResult" + } + } + }, + { + "name": "subscription.state", + "summary": "Current plan, tier catalog and usage for the picker; fail-open when logged out.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SubscriptionStateResult" + } + } + }, + { + "name": "subscription.upgrade", + "summary": "Prorate, charge and flip the plan (billing:manage, idempotent).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SubscriptionUpgradeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SubscriptionUpgradeResult" + } + } + }, + { + "name": "system.battery", + "summary": "Host battery for the status bar; always resolves, ``available: false`` when unreadable.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SystemBatteryParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SystemBatteryResult" + } + } + }, + { + "name": "terminal.resize", + "summary": "Record the client's column width for server-side rendering.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/TerminalResizeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/TerminalResizeResult" + } + } + }, + { + "name": "tools.configure", + "summary": "Persist a toolset / MCP enable-disable change and rebuild the session agent so it takes effect now.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ToolsConfigureParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ToolsConfigureResult" + } + } + }, + { + "name": "tools.list", + "summary": "Every toolset with its resolved tool names, flagged against the session's (or config's) enabled set.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/_SessionScoped" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ToolsetsListResult" + } + } + }, + { + "name": "tools.show", + "summary": "The /tools listing grouped by toolset, including tools deferred behind the tool_search bridge.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/_SessionScoped" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ToolsShowResult" + } + } + }, + { + "name": "toolsets.list", + "summary": "Toolset summaries (no tool names) for the desktop Toolsets tab.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/_SessionScoped" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ToolsetsListResult" + } + } + }, + { + "name": "usage.bars", + "summary": "Two-bar dollar usage view shared by /usage, /topup and /subscription; fail-open to unavailable.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/UsageModel" + } + } + }, + { + "name": "vault.add", + "summary": "Add a login / payment / address item to the local vault.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultAddParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VaultAddResult" + } + } + }, + { + "name": "vault.list", + "summary": "Metadata-only listing across the local vault and every unlocked password manager.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VaultListResult" + } + } + }, + { + "name": "vault.lock", + "summary": "Forget a manager's session token (every manager when no name is given).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultLockParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VaultLockResult" + } + } + }, + { + "name": "vault.remove", + "summary": "Remove a local vault item by id.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultRemoveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VaultRemoveResult" + } + } + }, + { + "name": "vault.source.set", + "summary": "Enable or disable an external password manager (disabling also locks it).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultSourceSetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VaultSourceSetResult" + } + } + }, + { + "name": "vault.sources", + "summary": "Status of every login source (local vault + detected password managers).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VaultSourcesResult" + } + } + }, + { + "name": "vault.unlock", + "summary": "Unlock a password manager for this session with its master password.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultUnlockParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VaultUnlockResult" + } + } + }, + { + "name": "verification.status", + "summary": "Best known verification evidence for a cwd/session; read-only, never runs checks.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VerificationStatusParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VerificationStatusResult" + } + } + }, + { + "name": "voice.record", + "summary": "VAD-bounded push-to-talk; the transcript arrives as a voice.transcript event.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VoiceRecordParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VoiceRecordResult" + } + } + }, + { + "name": "voice.toggle", + "summary": "/voice parity: report, flip voice mode on/off, or toggle speech output.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VoiceToggleParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VoiceToggleResult" + } + } + }, + { + "name": "voice.tts", + "summary": "Speak text through the backend TTS engine (barge-in aware).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VoiceTtsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VoiceTtsResult" + } + } + }, + { + "name": "wake.feed", + "summary": "Push client-captured PCM into the armed detector (mic-less remote backends).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/WakeFeedParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WakeFeedResult" + } + } + }, + { + "name": "wake.pause", + "summary": "Release the mic (e.g. while the desktop's browser captures audio).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/WakeControlParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WakePauseResult" + } + } + }, + { + "name": "wake.resume", + "summary": "Reclaim the mic after a pause; no-op if the listener isn't armed.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/WakeControlParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WakeResumeResult" + } + } + }, + { + "name": "wake.start", + "summary": "Arm the wake-word listener for the calling surface; refusals explain why.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/WakeStartParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WakeStartResult" + } + } + }, + { + "name": "wake.status", + "summary": "Everything a client needs to draw the wake-word state and decide whether to (re)arm.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/WakeStatusParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WakeStatusResult" + } + } + }, + { + "name": "wake.stop", + "summary": "Stop this surface's listener; persist also writes wake_word.enabled: false.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/WakeStopParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WakeStopResult" + } + } + } + ], + "components": { + "schemas": { + "AccountOwner": { + "additionalProperties": false, + "properties": { + "type": { + "const": "account", + "title": "Type", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AccountOwner", + "type": "object" + }, + "ActiveIdResult": { + "additionalProperties": false, + "properties": { + "active_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Active Id" + } + }, + "title": "ActiveIdResult", + "type": "object" + }, + "ActiveSubagent": { + "additionalProperties": true, + "description": "One live child from ``tools/delegate_tool_registry.py::list_active_subagents`` (the record\nis extended by the child runner \u2014 ``missed_steer`` etc. \u2014 so it stays open).", + "properties": { + "subagent_id": { + "title": "Subagent Id", + "type": "string" + }, + "parent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Id" + }, + "depth": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Depth" + }, + "goal": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Goal" + }, + "delegation_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Delegation Id" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "tool_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Count" + }, + "owner_agent_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Owner Agent Session Id" + } + }, + "required": [ + "subagent_id" + ], + "title": "ActiveSubagent", + "type": "object" + }, + "AgentPluginRow": { + "additionalProperties": false, + "description": "``methods_tools._plugin_rows`` + ``plugins_cmd_catalog.catalog_row_fields`` provenance.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "key": { + "title": "Key", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "portable": { + "title": "Portable", + "type": "boolean" + }, + "install_dir": { + "title": "Install Dir", + "type": "string" + }, + "has_desktop_half": { + "title": "Has Desktop Half", + "type": "boolean" + }, + "servers": { + "items": { + "$ref": "#/components/schemas/PluginServerRow" + }, + "title": "Servers", + "type": "array" + }, + "catalog_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Catalog Name" + }, + "catalog_tier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Catalog Tier" + }, + "installed_sha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Installed Sha" + }, + "catalog_sha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Catalog Sha" + }, + "catalog_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Catalog Version" + }, + "update_available": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Update Available" + }, + "pinned_sha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pinned Sha" + }, + "settings_schema": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/PluginSettingField" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Settings Schema" + } + }, + "required": [ + "name", + "key", + "version", + "description", + "source", + "status", + "portable", + "install_dir", + "has_desktop_half", + "servers" + ], + "title": "AgentPluginRow", + "type": "object" + }, + "AgentProcessRow": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "command": { + "title": "Command", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "uptime": { + "title": "Uptime", + "type": "integer" + } + }, + "required": [ + "session_id", + "command", + "status", + "uptime" + ], + "title": "AgentProcessRow", + "type": "object" + }, + "AgentsListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "AgentsListParams", + "type": "object" + }, + "AgentsListResult": { + "additionalProperties": false, + "properties": { + "processes": { + "items": { + "$ref": "#/components/schemas/AgentProcessRow" + }, + "title": "Processes", + "type": "array" + } + }, + "title": "AgentsListResult", + "type": "object" + }, + "ApprovalChoice": { + "enum": [ + "once", + "session", + "always", + "deny" + ], + "title": "ApprovalChoice", + "type": "string" + }, + "ApprovalPendingParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "ApprovalPendingParams", + "type": "object" + }, + "ApprovalPendingResult": { + "additionalProperties": false, + "properties": { + "approvals": { + "items": { + "$ref": "#/components/schemas/PendingApproval" + }, + "title": "Approvals", + "type": "array" + } + }, + "required": [ + "approvals" + ], + "title": "ApprovalPendingResult", + "type": "object" + }, + "ApprovalReceivedParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "request_id": { + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "session_id", + "request_id" + ], + "title": "ApprovalReceivedParams", + "type": "object" + }, + "ApprovalReceivedResult": { + "additionalProperties": false, + "properties": { + "acknowledged": { + "title": "Acknowledged", + "type": "boolean" + } + }, + "required": [ + "acknowledged" + ], + "title": "ApprovalReceivedResult", + "type": "object" + }, + "ApprovalRequestParams": { + "additionalProperties": true, + "description": "``tui_gateway/server.py::_approval_request_payload`` \u2014 the command is redacted server-side.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "request_id": { + "title": "Request Id", + "type": "string" + }, + "command": { + "default": "", + "title": "Command", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "choices": { + "items": { + "$ref": "#/components/schemas/ApprovalChoice" + }, + "title": "Choices", + "type": "array" + }, + "allow_permanent": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Allow Permanent" + }, + "allow_session": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Allow Session" + }, + "smart_denied": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Smart Denied" + }, + "tool_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Name" + }, + "gateway_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Gateway Session Id" + } + }, + "required": [ + "session_id", + "request_id" + ], + "title": "ApprovalRequestParams", + "type": "object" + }, + "ApprovalRespondParams": { + "additionalProperties": false, + "description": "``choice`` is one of the offered ``approval`` choices (once / session / always / deny); ``all``\nresolves every pending approval, ``request_id`` a specific one, neither the oldest.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "choice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Choice" + }, + "all": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "All" + }, + "request_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Request Id" + } + }, + "required": [ + "session_id" + ], + "title": "ApprovalRespondParams", + "type": "object" + }, + "ApprovalRespondResult": { + "additionalProperties": false, + "properties": { + "resolved": { + "title": "Resolved", + "type": "integer" + } + }, + "required": [ + "resolved" + ], + "title": "ApprovalRespondResult", + "type": "object" + }, + "ApprovalResult": { + "additionalProperties": false, + "properties": { + "choice": { + "$ref": "#/components/schemas/ApprovalChoice" + }, + "all": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "All" + } + }, + "required": [ + "choice" + ], + "title": "ApprovalResult", + "type": "object" + }, + "ArgumentMode": { + "enum": [ + "options", + "text", + "mixed" + ], + "title": "ArgumentMode", + "type": "string" + }, + "AttachedImageResult": { + "additionalProperties": false, + "description": "``methods_prompt.py::_attached_image_result``: the image is queued for the next turn.", + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "width": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Width" + }, + "height": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Height" + }, + "token_estimate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Token Estimate" + }, + "attached": { + "title": "Attached", + "type": "boolean" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + }, + "remainder": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Remainder" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Bytes" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + } + }, + "required": [ + "attached" + ], + "title": "AttachedImageResult", + "type": "object" + }, + "AutoContinue": { + "additionalProperties": false, + "description": "A crash-interrupted turn was scheduled to continue right after this resume.", + "properties": { + "attempt": { + "title": "Attempt", + "type": "integer" + }, + "interrupted_at": { + "title": "Interrupted At", + "type": "number" + } + }, + "required": [ + "attempt", + "interrupted_at" + ], + "title": "AutoContinue", + "type": "object" + }, + "AutoReloadCardKind": { + "enum": [ + "canonical", + "distinct", + "none" + ], + "title": "AutoReloadCardKind", + "type": "string" + }, + "BatteryCategory": { + "description": "``agent/battery.py::battery_category`` colour bucket.", + "enum": [ + "good", + "warn", + "bad", + "critical", + "dim" + ], + "title": "BatteryCategory", + "type": "string" + }, + "BillingAutoReload": { + "additionalProperties": false, + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "threshold_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Threshold Usd" + }, + "threshold_display": { + "title": "Threshold Display", + "type": "string" + }, + "reload_to_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reload To Usd" + }, + "reload_to_display": { + "title": "Reload To Display", + "type": "string" + }, + "card": { + "anyOf": [ + { + "$ref": "#/components/schemas/BillingAutoReloadCard" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "enabled", + "threshold_display", + "reload_to_display" + ], + "title": "BillingAutoReload", + "type": "object" + }, + "BillingAutoReloadCard": { + "additionalProperties": false, + "description": "Only ``distinct`` carries the payment-method identity.", + "properties": { + "kind": { + "$ref": "#/components/schemas/AutoReloadCardKind" + }, + "payment_method_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payment Method Id" + }, + "brand": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Brand" + }, + "last4": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last4" + } + }, + "required": [ + "kind" + ], + "title": "BillingAutoReloadCard", + "type": "object" + }, + "BillingAutoReloadParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "enabled": { + "default": false, + "title": "Enabled", + "type": "boolean" + }, + "threshold": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Threshold" + }, + "top_up_amount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Top Up Amount" + } + }, + "title": "BillingAutoReloadParams", + "type": "object" + }, + "BillingBlock": { + "additionalProperties": false, + "description": "``agent/billing_links.py::BillingBlock.to_dict`` (+ ``unverified`` from conversation_loop).", + "properties": { + "provider": { + "title": "Provider", + "type": "string" + }, + "provider_label": { + "title": "Provider Label", + "type": "string" + }, + "model": { + "title": "Model", + "type": "string" + }, + "billing_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Billing Url" + }, + "is_nous": { + "title": "Is Nous", + "type": "boolean" + }, + "message": { + "title": "Message", + "type": "string" + }, + "unverified": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unverified" + } + }, + "required": [ + "provider", + "provider_label", + "model", + "billing_url", + "is_nous", + "message" + ], + "title": "BillingBlock", + "type": "object" + }, + "BillingCardInfo": { + "additionalProperties": false, + "properties": { + "brand": { + "title": "Brand", + "type": "string" + }, + "last4": { + "title": "Last4", + "type": "string" + }, + "masked": { + "title": "Masked", + "type": "string" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "resolved_via": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resolved Via" + } + }, + "required": [ + "brand", + "last4", + "masked" + ], + "title": "BillingCardInfo", + "type": "object" + }, + "BillingChargeParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "amount_usd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Amount Usd" + }, + "idempotency_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Idempotency Key" + } + }, + "title": "BillingChargeParams", + "type": "object" + }, + "BillingChargeResult": { + "additionalProperties": false, + "description": "``202 {chargeId}`` \u2014 money is not confirmed yet; poll ``billing.charge_status``.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery" + }, + "charge_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Charge Id" + }, + "idempotency_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Idempotency Key" + } + }, + "required": [ + "ok" + ], + "title": "BillingChargeResult", + "type": "object" + }, + "BillingChargeStatusParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "charge_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Charge Id" + } + }, + "title": "BillingChargeStatusParams", + "type": "object" + }, + "BillingChargeStatusResult": { + "additionalProperties": false, + "description": "Single status read (pending | settled | failed); the caller drives the poll cadence.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "amount_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Amount Usd" + }, + "settled_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Settled At" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "ok" + ], + "title": "BillingChargeStatusResult", + "type": "object" + }, + "BillingMonthlyCap": { + "additionalProperties": false, + "properties": { + "limit_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Limit Usd" + }, + "limit_display": { + "title": "Limit Display", + "type": "string" + }, + "spent_this_month_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spent This Month Usd" + }, + "spent_display": { + "title": "Spent Display", + "type": "string" + }, + "is_default_ceiling": { + "title": "Is Default Ceiling", + "type": "boolean" + } + }, + "required": [ + "limit_display", + "spent_display", + "is_default_ceiling" + ], + "title": "BillingMonthlyCap", + "type": "object" + }, + "BillingMutationResult": { + "additionalProperties": false, + "description": "A write with no success payload beyond ``ok``.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery" + } + }, + "required": [ + "ok" + ], + "title": "BillingMutationResult", + "type": "object" + }, + "BillingPaymentMethod": { + "additionalProperties": false, + "description": "``_serialize_payment_method``: each kind emits only its own fields (a ``card`` never carries\n``email``; ``unknown`` carries what the server called it in ``raw_kind``).", + "properties": { + "kind": { + "$ref": "#/components/schemas/PaymentMethodKind" + }, + "brand": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Brand" + }, + "last4": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last4" + }, + "wallet": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Wallet" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Email" + }, + "raw_kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Kind" + }, + "resolved_via": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resolved Via" + } + }, + "required": [ + "kind" + ], + "title": "BillingPaymentMethod", + "type": "object" + }, + "BillingPendingChangeResult": { + "additionalProperties": false, + "description": "``_billing_pending_change``: ``message`` + the raw NAS body in ``payload`` on success.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery" + } + }, + "required": [ + "ok" + ], + "title": "BillingPendingChangeResult", + "type": "object" + }, + "BillingStateResult": { + "additionalProperties": false, + "description": "``_serialize_billing_state`` (money as strings); the ``except`` fallback emits only\n``ok / logged_in / free_tier_account / error``, so everything else is optional.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "logged_in": { + "title": "Logged In", + "type": "boolean" + }, + "free_tier_account": { + "default": false, + "title": "Free Tier Account", + "type": "boolean" + }, + "free_tier_model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Tier Model" + }, + "org_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Org Name" + }, + "org_slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Org Slug" + }, + "role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Role" + }, + "is_admin": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is Admin" + }, + "can_change_plan": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Can Change Plan" + }, + "can_charge": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Can Charge" + }, + "balance_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Balance Usd" + }, + "balance_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Balance Display" + }, + "cli_billing_enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cli Billing Enabled" + }, + "charge_presets": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Charge Presets" + }, + "charge_presets_display": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Charge Presets Display" + }, + "min_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Min Usd" + }, + "max_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Usd" + }, + "card": { + "anyOf": [ + { + "$ref": "#/components/schemas/BillingCardInfo" + }, + { + "type": "null" + } + ], + "default": null + }, + "payment_method": { + "anyOf": [ + { + "$ref": "#/components/schemas/BillingPaymentMethod" + }, + { + "type": "null" + } + ], + "default": null + }, + "monthly_cap": { + "anyOf": [ + { + "$ref": "#/components/schemas/BillingMonthlyCap" + }, + { + "type": "null" + } + ], + "default": null + }, + "auto_reload": { + "anyOf": [ + { + "$ref": "#/components/schemas/BillingAutoReload" + }, + { + "type": "null" + } + ], + "default": null + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/components/schemas/UsageModel" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "ok", + "logged_in" + ], + "title": "BillingStateResult", + "type": "object" + }, + "BillingStepUpParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "title": "BillingStepUpParams", + "type": "object" + }, + "BillingStepUpResult": { + "additionalProperties": false, + "description": "``granted`` false when the server downscopes (also on every error envelope).", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery" + }, + "granted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Granted" + } + }, + "required": [ + "ok" + ], + "title": "BillingStepUpResult", + "type": "object" + }, + "BillingStepUpVerificationPayload": { + "additionalProperties": false, + "description": "``methods_session`` billing.step_up on_verification.", + "properties": { + "verification_url": { + "title": "Verification Url", + "type": "string" + }, + "user_code": { + "title": "User Code", + "type": "string" + } + }, + "required": [ + "verification_url", + "user_code" + ], + "title": "BillingStepUpVerificationPayload", + "type": "object" + }, + "BotRelayDeliverParams": { + "additionalProperties": false, + "description": "``profile`` here is the TARGET profile on this gateway (also what the desktop route wrapper adds).", + "properties": { + "profile": { + "title": "Profile", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "from_profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "From Profile" + }, + "from_handle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "From Handle" + }, + "from_connection": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "From Connection" + } + }, + "required": [ + "profile", + "message" + ], + "title": "BotRelayDeliverParams", + "type": "object" + }, + "BotRelayDeliverResult": { + "additionalProperties": false, + "properties": { + "reply": { + "title": "Reply", + "type": "string" + } + }, + "required": [ + "reply" + ], + "title": "BotRelayDeliverResult", + "type": "object" + }, + "BotRelayOutboxDrainParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "BotRelayOutboxDrainParams", + "type": "object" + }, + "BotRelayOutboxDrainResult": { + "additionalProperties": false, + "properties": { + "envelopes": { + "items": { + "$ref": "#/components/schemas/RelayEnvelope" + }, + "title": "Envelopes", + "type": "array" + } + }, + "required": [ + "envelopes" + ], + "title": "BotRelayOutboxDrainResult", + "type": "object" + }, + "BotRelayReplyParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "title": "Id", + "type": "string" + }, + "reply": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reply" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "id" + ], + "title": "BotRelayReplyParams", + "type": "object" + }, + "BotRelayRosterSyncParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "agents": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/RelayAgentRow" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Agents" + } + }, + "title": "BotRelayRosterSyncParams", + "type": "object" + }, + "BotRelayRosterSyncResult": { + "additionalProperties": false, + "properties": { + "count": { + "title": "Count", + "type": "integer" + } + }, + "required": [ + "count" + ], + "title": "BotRelayRosterSyncResult", + "type": "object" + }, + "BrowserAction": { + "enum": [ + "status", + "connect", + "disconnect" + ], + "title": "BrowserAction", + "type": "string" + }, + "BrowserControllerCancelPayload": { + "additionalProperties": false, + "description": "``gateway/browser_control_broker.py::_cancel_frame``.", + "properties": { + "command_id": { + "title": "Command Id", + "type": "string" + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Call Id" + } + }, + "required": [ + "command_id" + ], + "title": "BrowserControllerCancelPayload", + "type": "object" + }, + "BrowserControllerCommandPayload": { + "additionalProperties": false, + "description": "``gateway/browser_control_broker.py`` FRAME_COMMAND params.", + "properties": { + "command_id": { + "title": "Command Id", + "type": "string" + }, + "action": { + "title": "Action", + "type": "string" + }, + "arguments": { + "additionalProperties": true, + "title": "Arguments", + "type": "object" + }, + "controller_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Controller Id" + }, + "browser_profile_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Browser Profile Id" + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Call Id" + } + }, + "required": [ + "command_id", + "action", + "arguments" + ], + "title": "BrowserControllerCommandPayload", + "type": "object" + }, + "BrowserControllerDetachResult": { + "additionalProperties": false, + "properties": { + "detached": { + "default": true, + "title": "Detached", + "type": "boolean" + } + }, + "title": "BrowserControllerDetachResult", + "type": "object" + }, + "BrowserControllerParams": { + "additionalProperties": false, + "description": "Every controller call names the session the controller is attached to.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + } + }, + "required": [ + "session_id" + ], + "title": "BrowserControllerParams", + "type": "object" + }, + "BrowserControllerRegisterParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "controller_id": { + "title": "Controller Id", + "type": "string" + }, + "browser_profile_id": { + "title": "Browser Profile Id", + "type": "string" + }, + "capabilities": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Capabilities" + }, + "protocol_version": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Protocol Version" + }, + "principal_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Principal Id" + } + }, + "required": [ + "session_id", + "controller_id", + "browser_profile_id" + ], + "title": "BrowserControllerRegisterParams", + "type": "object" + }, + "BrowserControllerRegisterResult": { + "additionalProperties": false, + "properties": { + "scope": { + "$ref": "#/components/schemas/ControllerScope" + } + }, + "required": [ + "scope" + ], + "title": "BrowserControllerRegisterResult", + "type": "object" + }, + "BrowserControllerResultParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "command_id": { + "title": "Command Id", + "type": "string" + }, + "ok": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Ok" + }, + "result": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Result" + }, + "error": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + } + }, + "required": [ + "session_id", + "command_id" + ], + "title": "BrowserControllerResultParams", + "type": "object" + }, + "BrowserControllerResultResult": { + "additionalProperties": false, + "properties": { + "accepted": { + "title": "Accepted", + "type": "boolean" + } + }, + "required": [ + "accepted" + ], + "title": "BrowserControllerResultResult", + "type": "object" + }, + "BrowserManageParams": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/BrowserAction", + "default": "status" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "BrowserManageParams", + "type": "object" + }, + "BrowserManageResult": { + "additionalProperties": false, + "properties": { + "connected": { + "title": "Connected", + "type": "boolean" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "messages": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Messages" + } + }, + "required": [ + "connected" + ], + "title": "BrowserManageResult", + "type": "object" + }, + "BrowserProgressPayload": { + "additionalProperties": false, + "description": "``methods_browser`` announce(); ``level``: info | warn | error.", + "properties": { + "message": { + "title": "Message", + "type": "string" + }, + "level": { + "title": "Level", + "type": "string" + } + }, + "required": [ + "message", + "level" + ], + "title": "BrowserProgressPayload", + "type": "object" + }, + "CapabilityEntry": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + } + }, + "required": [ + "name" + ], + "title": "CapabilityEntry", + "type": "object" + }, + "CatalogAppState": { + "description": "The desktop app a catalog plugin drives, from its ``hermes_platform`` declaration.", + "enum": [ + "present", + "missing_app", + "app_not_running", + "unknown" + ], + "title": "CatalogAppState", + "type": "string" + }, + "CatalogScan": { + "additionalProperties": false, + "description": "The catalog's security scan of the pinned commit; read-only on the card.", + "properties": { + "status": { + "$ref": "#/components/schemas/CatalogScanStatus" + }, + "summary": { + "title": "Summary", + "type": "string" + } + }, + "required": [ + "status", + "summary" + ], + "title": "CatalogScan", + "type": "object" + }, + "CatalogScanStatus": { + "enum": [ + "passed", + "warnings", + "failed" + ], + "title": "CatalogScanStatus", + "type": "string" + }, + "CatalogTier": { + "enum": [ + "official", + "community" + ], + "title": "CatalogTier", + "type": "string" + }, + "ChangeSignalPayload": { + "additionalProperties": true, + "description": "``change_watcher._CHANGE_WATCHES`` payload fn \u2014 ``{}`` for every watch except pet.changed.", + "properties": {}, + "title": "ChangeSignalPayload", + "type": "object" + }, + "ClarifyLockParams": { + "additionalProperties": false, + "properties": { + "request_id": { + "title": "Request Id", + "type": "string" + }, + "question_id": { + "title": "Question Id", + "type": "string" + }, + "answer": { + "default": "", + "title": "Answer" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "request_id", + "question_id" + ], + "title": "ClarifyLockParams", + "type": "object" + }, + "ClarifyLockResult": { + "additionalProperties": false, + "description": "``remaining`` lists the qids still unanswered; the lock that empties it resolves the request.\n``expired``: the wait already ended (timeout / cancel) \u2014 not an error.", + "properties": { + "status": { + "$ref": "#/components/schemas/ClarifyLockStatus" + }, + "remaining": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Remaining" + } + }, + "required": [ + "status" + ], + "title": "ClarifyLockResult", + "type": "object" + }, + "ClarifyLockStatus": { + "enum": [ + "ok", + "expired" + ], + "title": "ClarifyLockStatus", + "type": "string" + }, + "ClarifyQuestion": { + "additionalProperties": false, + "properties": { + "qid": { + "title": "Qid", + "type": "string" + }, + "question": { + "title": "Question", + "type": "string" + }, + "choices": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Choices" + }, + "multi_select": { + "default": false, + "title": "Multi Select", + "type": "boolean" + } + }, + "required": [ + "qid", + "question" + ], + "title": "ClarifyQuestion", + "type": "object" + }, + "ClarifyRequestParams": { + "additionalProperties": false, + "description": "Single question: ``question`` / ``choices`` (/ ``multi_select``); batch: ``questions``.\n``answers`` rides only on a reconnect replay (locks the server already accepted).", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "question": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Question" + }, + "choices": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Choices" + }, + "multi_select": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Multi Select" + }, + "questions": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ClarifyQuestion" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Questions" + }, + "answers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Answers" + } + }, + "required": [ + "session_id" + ], + "title": "ClarifyRequestParams", + "type": "object" + }, + "ClarifyResult": { + "additionalProperties": false, + "description": "Single: ``{answer}`` ('' = skip). Batch: ``{answers}`` for the whole set (early locks go through\nthe ``clarify.lock`` RPC); a response with neither is cancel-all.", + "properties": { + "answer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Answer" + }, + "answers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Answers" + } + }, + "title": "ClarifyResult", + "type": "object" + }, + "CliExecParams": { + "additionalProperties": false, + "properties": { + "argv": { + "items": { + "type": "string" + }, + "title": "Argv", + "type": "array" + }, + "timeout": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Timeout" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "argv" + ], + "title": "CliExecParams", + "type": "object" + }, + "CliExecResult": { + "additionalProperties": false, + "properties": { + "blocked": { + "title": "Blocked", + "type": "boolean" + }, + "code": { + "title": "Code", + "type": "integer" + }, + "output": { + "title": "Output", + "type": "string" + }, + "hint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hint" + } + }, + "required": [ + "blocked", + "code", + "output" + ], + "title": "CliExecResult", + "type": "object" + }, + "ClientCapabilitiesParams": { + "additionalProperties": false, + "properties": { + "server_requests": { + "default": false, + "title": "Server Requests", + "type": "boolean" + } + }, + "title": "ClientCapabilitiesParams", + "type": "object" + }, + "ClientCapabilitiesResult": { + "additionalProperties": false, + "properties": { + "server_requests": { + "items": { + "type": "string" + }, + "title": "Server Requests", + "type": "array" + }, + "declines_not_shown": { + "default": false, + "title": "Declines Not Shown", + "type": "boolean" + } + }, + "required": [ + "server_requests" + ], + "title": "ClientCapabilitiesResult", + "type": "object" + }, + "ClipboardPasteParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "ClipboardPasteParams", + "type": "object" + }, + "CommandCatalogMeta": { + "additionalProperties": false, + "properties": { + "argument_mode": { + "anyOf": [ + { + "$ref": "#/components/schemas/ArgumentMode" + }, + { + "type": "null" + } + ], + "default": null + }, + "desktop": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Desktop" + } + }, + "title": "CommandCatalogMeta", + "type": "object" + }, + "CommandCategory": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "pairs": { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Pairs", + "type": "array" + } + }, + "required": [ + "name" + ], + "title": "CommandCategory", + "type": "object" + }, + "CommandDispatchParams": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "arg": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Arg" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "name" + ], + "title": "CommandDispatchParams", + "type": "object" + }, + "CommandDispatchResult": { + "additionalProperties": false, + "description": "One structured directive: ``exec``/``plugin`` carry ``output``; ``alias`` a ``target``;\n``send``/``prefill``/``skill`` a ``message`` (UIs render ``display``, never ``message``).", + "properties": { + "type": { + "$ref": "#/components/schemas/DispatchType" + }, + "output": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Output" + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "notice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Notice" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + } + }, + "required": [ + "type" + ], + "title": "CommandDispatchResult", + "type": "object" + }, + "CommandResolveParams": { + "additionalProperties": false, + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "CommandResolveParams", + "type": "object" + }, + "CommandResolveResult": { + "additionalProperties": false, + "properties": { + "canonical": { + "title": "Canonical", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "category": { + "title": "Category", + "type": "string" + } + }, + "required": [ + "canonical", + "description", + "category" + ], + "title": "CommandResolveResult", + "type": "object" + }, + "CommandsCatalogParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "CommandsCatalogParams", + "type": "object" + }, + "CommandsCatalogResult": { + "additionalProperties": false, + "properties": { + "pairs": { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Pairs", + "type": "array" + }, + "sub": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Sub", + "type": "object" + }, + "canon": { + "additionalProperties": { + "type": "string" + }, + "title": "Canon", + "type": "object" + }, + "commands": { + "additionalProperties": { + "$ref": "#/components/schemas/CommandCatalogMeta" + }, + "title": "Commands", + "type": "object" + }, + "categories": { + "items": { + "$ref": "#/components/schemas/CommandCategory" + }, + "title": "Categories", + "type": "array" + }, + "skills": { + "additionalProperties": { + "$ref": "#/components/schemas/SkillCatalogEntry" + }, + "title": "Skills", + "type": "object" + }, + "skill_count": { + "default": 0, + "title": "Skill Count", + "type": "integer" + }, + "warning": { + "default": "", + "title": "Warning", + "type": "string" + } + }, + "title": "CommandsCatalogResult", + "type": "object" + }, + "CompletePathParams": { + "additionalProperties": false, + "description": "``word`` is the token under the cursor (``@`` prefix = context reference); ``cwd`` /\n``session_id`` pick the directory the listing resolves against.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "word": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Word" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "title": "CompletePathParams", + "type": "object" + }, + "CompleteSlashParams": { + "additionalProperties": false, + "description": "``session_id`` binds skill completions to that session's profile and workspace (project skills);\n``profile`` scopes a session-less request (a new-chat draft).", + "properties": { + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "CompleteSlashParams", + "type": "object" + }, + "CompleteSlashResult": { + "additionalProperties": false, + "description": "``replace_from`` is the column the accepted item replaces from.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/CompletionItem" + }, + "title": "Items", + "type": "array" + }, + "replace_from": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Replace From" + } + }, + "title": "CompleteSlashResult", + "type": "object" + }, + "CompletionItem": { + "additionalProperties": false, + "description": "One popover row; ``kind`` rides only on slash completions (command vs skill).", + "properties": { + "text": { + "title": "Text", + "type": "string" + }, + "display": { + "default": "", + "title": "Display", + "type": "string" + }, + "meta": { + "default": "", + "title": "Meta", + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Kind" + } + }, + "required": [ + "text" + ], + "title": "CompletionItem", + "type": "object" + }, + "CompletionItemsResult": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/CompletionItem" + }, + "title": "Items", + "type": "array" + } + }, + "title": "CompletionItemsResult", + "type": "object" + }, + "CompressionSummary": { + "additionalProperties": true, + "description": "``agent.manual_compression_feedback.summarize_manual_compression``.", + "properties": { + "noop": { + "default": false, + "title": "Noop", + "type": "boolean" + }, + "aborted": { + "default": false, + "title": "Aborted", + "type": "boolean" + }, + "refused_would_grow": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Refused Would Grow" + }, + "fallback_used": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Fallback Used" + }, + "headline": { + "default": "", + "title": "Headline", + "type": "string" + }, + "token_line": { + "default": "", + "title": "Token Line", + "type": "string" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Note" + } + }, + "title": "CompressionSummary", + "type": "object" + }, + "ConfigGetParams": { + "additionalProperties": false, + "description": "``key`` selects one getter from ``_CONFIG_GETTERS``; ``cwd`` feeds the ``project`` getter,\n``session_id`` lets ``reasoning`` / ``fast`` answer with the session's live pin.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "key": { + "title": "Key", + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "required": [ + "key" + ], + "title": "ConfigGetParams", + "type": "object" + }, + "ConfigGetResult": { + "additionalProperties": false, + "description": "Union of every getter's payload: ``value`` for the simple words, ``config`` for ``full``,\n``mtime`` / ``mcp_rev`` for the poller, ``model`` / ``provider`` / ``providers`` for ``provider``,\n``home`` / ``display`` for ``profile``, ``cwd`` / ``branch`` for ``project``, ``prompt``.", + "properties": { + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "tool_progress": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Progress" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "providers": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ConfigProviderRef" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Providers" + }, + "home": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Home" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Branch" + }, + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Config" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompt" + }, + "mtime": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mtime" + }, + "mcp_rev": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mcp Rev" + } + }, + "title": "ConfigGetResult", + "type": "object" + }, + "ConfigProviderRef": { + "additionalProperties": true, + "description": "``hermes_cli/models.py::list_available_providers`` row.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "aliases": { + "items": { + "type": "string" + }, + "title": "Aliases", + "type": "array" + }, + "authenticated": { + "default": false, + "title": "Authenticated", + "type": "boolean" + } + }, + "required": [ + "id", + "label" + ], + "title": "ConfigProviderRef", + "type": "object" + }, + "ConfigSection": { + "additionalProperties": false, + "properties": { + "title": { + "title": "Title", + "type": "string" + }, + "rows": { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Rows", + "type": "array" + } + }, + "required": [ + "title" + ], + "title": "ConfigSection", + "type": "object" + }, + "ConfigSetParams": { + "additionalProperties": false, + "description": "``key`` picks the setter (``_CONFIG_SETTERS``, ``details_mode.
``, display toggles);\n``value`` is the raw word/string the setter normalises (falsy non-strings are reported back in\nthe error). ``scope`` applies to ``yolo`` / ``reasoning``; ``confirm_expensive_model`` to ``model``.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "key": { + "title": "Key", + "type": "string" + }, + "value": { + "default": "", + "title": "Value" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "scope": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scope" + }, + "confirm_expensive_model": { + "default": false, + "title": "Confirm Expensive Model", + "type": "boolean" + } + }, + "required": [ + "key" + ], + "title": "ConfigSetParams", + "type": "object" + }, + "ConfigSetResult": { + "additionalProperties": false, + "description": "``{key, value}`` plus the setter's extras: model switches add ``warning`` /\n``confirm_required`` / ``confirm_message`` / ``scope`` / ``deferred``; ``focus`` adds\n``tool_progress``; ``cwd`` adds ``cwd`` / ``branch``; ``personality`` adds ``history_reset`` /\n``info``; ``yolo`` reports its ``scope``. ``value`` is a bool only for the display toggles.", + "properties": { + "key": { + "title": "Key", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value" + }, + "warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Warning" + }, + "confirm_required": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm Required" + }, + "confirm_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm Message" + }, + "scope": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scope" + }, + "deferred": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Deferred" + }, + "tool_progress": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Progress" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Branch" + }, + "history_reset": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "History Reset" + }, + "info": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionLiveInfo" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "key" + ], + "title": "ConfigSetResult", + "type": "object" + }, + "ConfigShowParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "ConfigShowParams", + "type": "object" + }, + "ConfigShowResult": { + "additionalProperties": true, + "properties": { + "sections": { + "items": { + "$ref": "#/components/schemas/ConfigSection" + }, + "title": "Sections", + "type": "array" + } + }, + "title": "ConfigShowResult", + "type": "object" + }, + "ConnectionActor": { + "description": "``tools/connectors/contract.py::Actor``.", + "enum": [ + "user", + "backend_watcher", + "clock" + ], + "title": "ConnectionActor", + "type": "string" + }, + "ConnectionAnswer": { + "additionalProperties": false, + "description": "The card's answer: per-target outcomes and an optional Continue\n(``settled_by: \"continue\"``). Settlement is derived from target states afterwards.", + "properties": { + "targets": { + "items": { + "$ref": "#/components/schemas/ConnectionAnswerTarget" + }, + "title": "Targets", + "type": "array" + }, + "settled_by": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionSettleReason" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "ConnectionAnswer", + "type": "object" + }, + "ConnectionAnswerStatus": { + "description": "What the card says about one row: ``tools/connectors/mcp.py::apply_answer``.", + "enum": [ + "approved", + "skipped" + ], + "title": "ConnectionAnswerStatus", + "type": "string" + }, + "ConnectionAnswerTarget": { + "additionalProperties": false, + "description": "One row's answer from the card. ``env`` carries the credential values an install asked for\nthrough ``required_env``.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ConnectionAnswerStatus" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Detail" + }, + "env": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Env" + } + }, + "required": [ + "name", + "status" + ], + "title": "ConnectionAnswerTarget", + "type": "object" + }, + "ConnectionOperationParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "owner": { + "discriminator": { + "mapping": { + "account": "#/components/schemas/AccountOwner", + "session": "#/components/schemas/SessionOwner" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SessionOwner" + }, + { + "$ref": "#/components/schemas/AccountOwner" + } + ], + "title": "Owner" + }, + "op_id": { + "title": "Op Id", + "type": "string" + } + }, + "required": [ + "owner", + "op_id" + ], + "title": "ConnectionOperationParams", + "type": "object" + }, + "ConnectionOperationStatus": { + "additionalProperties": false, + "description": "``methods_connectors._operation_view``: the operation's full snapshot.", + "properties": { + "op_id": { + "title": "Op Id", + "type": "string" + }, + "seq": { + "title": "Seq", + "type": "integer" + }, + "deadline_at": { + "title": "Deadline At", + "type": "number" + }, + "settled": { + "title": "Settled", + "type": "boolean" + }, + "settled_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Settled At" + }, + "settled_by": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionSettleReason" + }, + { + "type": "null" + } + ], + "default": null + }, + "targets": { + "items": { + "$ref": "#/components/schemas/ConnectionOperationTarget" + }, + "title": "Targets", + "type": "array" + } + }, + "required": [ + "op_id", + "seq", + "deadline_at", + "settled", + "targets" + ], + "title": "ConnectionOperationStatus", + "type": "object" + }, + "ConnectionOperationTarget": { + "additionalProperties": false, + "description": "``Target.snapshot``: the link minted up front rides here, never in the model result. ``extra``\nkeys a leg records (``tools``, ``hint``) are typed here as they appear.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/ConnectionTargetKind" + }, + "action": { + "$ref": "#/components/schemas/ConnectionTargetAction" + }, + "state": { + "$ref": "#/components/schemas/ConnectionTargetState" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Detail" + }, + "instructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Instructions" + }, + "discovery_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Discovery Error" + }, + "connect_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Connect Url" + }, + "connection_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Connection Id" + }, + "attempt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Attempt" + }, + "required_env": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ConnectionTargetEnvField" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Required Env" + }, + "tools": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tools" + }, + "hint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hint" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "tier": { + "anyOf": [ + { + "$ref": "#/components/schemas/CatalogTier" + }, + { + "type": "null" + } + ], + "default": null + }, + "platforms": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Platforms" + }, + "repo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repo" + }, + "sha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sha" + }, + "subdir": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subdir" + }, + "scan": { + "anyOf": [ + { + "$ref": "#/components/schemas/CatalogScan" + }, + { + "type": "null" + } + ], + "default": null + }, + "requirements": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Requirements" + }, + "has_desktop_half": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Has Desktop Half" + }, + "target_profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target Profile" + }, + "app_state": { + "anyOf": [ + { + "$ref": "#/components/schemas/CatalogAppState" + }, + { + "type": "null" + } + ], + "default": null + }, + "skill": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skill" + } + }, + "required": [ + "name", + "kind", + "action", + "state" + ], + "title": "ConnectionOperationTarget", + "type": "object" + }, + "ConnectionRequestPayload": { + "additionalProperties": false, + "description": "``ConnectionOperation.request_payload``: opens the card; also the ``pending_connection`` resume\nsnapshot so a client that missed the event restores the card with the server's deadline.", + "properties": { + "op_id": { + "title": "Op Id", + "type": "string" + }, + "seq": { + "title": "Seq", + "type": "integer" + }, + "deadline_at": { + "title": "Deadline At", + "type": "number" + }, + "timeout_seconds": { + "title": "Timeout Seconds", + "type": "number" + }, + "targets": { + "items": { + "$ref": "#/components/schemas/ConnectionOperationTarget" + }, + "title": "Targets", + "type": "array" + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Call Id" + } + }, + "required": [ + "op_id", + "seq", + "deadline_at", + "timeout_seconds", + "targets" + ], + "title": "ConnectionRequestPayload", + "type": "object" + }, + "ConnectionRespondParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "owner": { + "discriminator": { + "mapping": { + "account": "#/components/schemas/AccountOwner", + "session": "#/components/schemas/SessionOwner" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SessionOwner" + }, + { + "$ref": "#/components/schemas/AccountOwner" + } + ], + "title": "Owner" + }, + "op_id": { + "title": "Op Id", + "type": "string" + }, + "result": { + "$ref": "#/components/schemas/ConnectionAnswer" + } + }, + "required": [ + "owner", + "op_id", + "result" + ], + "title": "ConnectionRespondParams", + "type": "object" + }, + "ConnectionRespondResult": { + "additionalProperties": false, + "properties": { + "status": { + "const": "ok", + "title": "Status", + "type": "string" + }, + "settled": { + "title": "Settled", + "type": "boolean" + } + }, + "required": [ + "status", + "settled" + ], + "title": "ConnectionRespondResult", + "type": "object" + }, + "ConnectionSettleReason": { + "description": "``tools/connectors/contract.py::SettleReason``.", + "enum": [ + "all_resolved", + "continue", + "deadline", + "interrupt" + ], + "title": "ConnectionSettleReason", + "type": "string" + }, + "ConnectionTargetAction": { + "enum": [ + "authorize", + "connect", + "enable", + "install", + "reconnect" + ], + "title": "ConnectionTargetAction", + "type": "string" + }, + "ConnectionTargetEnvField": { + "additionalProperties": false, + "description": "One credential an MCP install still needs; the card renders a field per entry and sends the\nvalues back with the approval.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "required": { + "title": "Required", + "type": "boolean" + }, + "secret": { + "title": "Secret", + "type": "boolean" + }, + "default": { + "type": "string", + "title": "Default" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompt" + } + }, + "required": [ + "name", + "required", + "secret", + "default" + ], + "title": "ConnectionTargetEnvField", + "type": "object" + }, + "ConnectionTargetKind": { + "enum": [ + "connector", + "mcp", + "plugin", + "skill" + ], + "title": "ConnectionTargetKind", + "type": "string" + }, + "ConnectionTargetState": { + "description": "``tools/connectors/contract.py::TargetState``.", + "enum": [ + "pending", + "initiated", + "connected", + "skipped", + "failed", + "expired", + "not_connected" + ], + "title": "ConnectionTargetState", + "type": "string" + }, + "ConnectionUpdatePayload": { + "additionalProperties": false, + "description": "``methods_connectors._connection_update``: one target transition (``target``/``from``/``to``/\n``actor``) or the settlement (none of those), with the full snapshot.", + "properties": { + "op_id": { + "title": "Op Id", + "type": "string" + }, + "seq": { + "title": "Seq", + "type": "integer" + }, + "deadline_at": { + "title": "Deadline At", + "type": "number" + }, + "settled": { + "title": "Settled", + "type": "boolean" + }, + "settled_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Settled At" + }, + "settled_by": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionSettleReason" + }, + { + "type": "null" + } + ], + "default": null + }, + "targets": { + "items": { + "$ref": "#/components/schemas/ConnectionOperationTarget" + }, + "title": "Targets", + "type": "array" + }, + "owner": { + "discriminator": { + "mapping": { + "account": "#/components/schemas/AccountOwner", + "session": "#/components/schemas/SessionOwner" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SessionOwner" + }, + { + "$ref": "#/components/schemas/AccountOwner" + } + ], + "title": "Owner" + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target" + }, + "from": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionTargetState" + }, + { + "type": "null" + } + ], + "default": null + }, + "to": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionTargetState" + }, + { + "type": "null" + } + ], + "default": null + }, + "actor": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionActor" + }, + { + "type": "null" + } + ], + "default": null + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Detail" + } + }, + "required": [ + "op_id", + "seq", + "deadline_at", + "settled", + "targets", + "owner" + ], + "title": "ConnectionUpdatePayload", + "type": "object" + }, + "ConnectionWakeResult": { + "additionalProperties": false, + "properties": { + "status": { + "const": "ok", + "title": "Status", + "type": "string" + } + }, + "required": [ + "status" + ], + "title": "ConnectionWakeResult", + "type": "object" + }, + "ConnectorAccountRow": { + "additionalProperties": false, + "properties": { + "connection_id": { + "title": "Connection Id", + "type": "string" + }, + "connector": { + "title": "Connector", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ConnectorAccountStatus" + }, + "status_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status Reason" + }, + "label": { + "title": "Label", + "type": "string" + }, + "alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Alias" + }, + "active": { + "title": "Active", + "type": "boolean" + }, + "created_at": { + "title": "Created At", + "type": "string" + }, + "updated_at": { + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "connection_id", + "connector", + "status", + "label", + "active", + "created_at", + "updated_at" + ], + "title": "ConnectorAccountRow", + "type": "object" + }, + "ConnectorAccountStatus": { + "enum": [ + "pending", + "active", + "failed", + "expired", + "revoked", + "inactive" + ], + "title": "ConnectorAccountStatus", + "type": "string" + }, + "ConnectorAccountsParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "connector": { + "anyOf": [ + { + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Connector" + } + }, + "title": "ConnectorAccountsParams", + "type": "object" + }, + "ConnectorAccountsRemoveParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "connection_id": { + "minLength": 1, + "title": "Connection Id", + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "title": "ConnectorAccountsRemoveParams", + "type": "object" + }, + "ConnectorAccountsRemoveResult": { + "additionalProperties": false, + "properties": { + "connection_id": { + "title": "Connection Id", + "type": "string" + }, + "connector": { + "title": "Connector", + "type": "string" + }, + "status": { + "const": "removed", + "title": "Status", + "type": "string" + } + }, + "required": [ + "connection_id", + "connector", + "status" + ], + "title": "ConnectorAccountsRemoveResult", + "type": "object" + }, + "ConnectorAccountsResult": { + "additionalProperties": false, + "properties": { + "accounts": { + "items": { + "$ref": "#/components/schemas/ConnectorAccountRow" + }, + "title": "Accounts", + "type": "array" + } + }, + "required": [ + "accounts" + ], + "title": "ConnectorAccountsResult", + "type": "object" + }, + "ConnectorCatalogRow": { + "additionalProperties": false, + "properties": { + "slug": { + "title": "Slug", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "category": { + "title": "Category", + "type": "string" + }, + "logo_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Logo Url" + } + }, + "required": [ + "slug", + "name", + "description", + "category" + ], + "title": "ConnectorCatalogRow", + "type": "object" + }, + "ConnectorChange": { + "additionalProperties": false, + "properties": { + "type": { + "const": "connector", + "title": "Type", + "type": "string" + }, + "connector": { + "title": "Connector", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + } + }, + "required": [ + "type", + "connector", + "enabled" + ], + "title": "ConnectorChange", + "type": "object" + }, + "ConnectorPolicyAllowBody": { + "additionalProperties": false, + "properties": { + "mode": { + "const": "allow", + "title": "Mode", + "type": "string" + }, + "connectors": { + "items": { + "type": "string" + }, + "title": "Connectors", + "type": "array" + }, + "tools": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Tools", + "type": "object" + }, + "tags": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectorPolicyTags" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "mode", + "connectors", + "tools" + ], + "title": "ConnectorPolicyAllowBody", + "type": "object" + }, + "ConnectorPolicyDenyAllBody": { + "additionalProperties": false, + "properties": { + "mode": { + "const": "deny-all", + "title": "Mode", + "type": "string" + } + }, + "required": [ + "mode" + ], + "title": "ConnectorPolicyDenyAllBody", + "type": "object" + }, + "ConnectorPolicyDenyBody": { + "additionalProperties": false, + "properties": { + "mode": { + "const": "deny", + "title": "Mode", + "type": "string" + }, + "disabled_connectors": { + "items": { + "type": "string" + }, + "title": "Disabled Connectors", + "type": "array" + }, + "tools": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Tools", + "type": "object" + }, + "tags": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectorPolicyTags" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "mode", + "disabled_connectors", + "tools" + ], + "title": "ConnectorPolicyDenyBody", + "type": "object" + }, + "ConnectorPolicyEffectiveAllow": { + "additionalProperties": false, + "properties": { + "version": { + "const": 1, + "title": "Version", + "type": "integer" + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "issued_at_ms": { + "title": "Issued At Ms", + "type": "integer" + }, + "mode": { + "const": "allow", + "title": "Mode", + "type": "string" + }, + "connectors": { + "items": { + "type": "string" + }, + "title": "Connectors", + "type": "array" + }, + "tools": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Tools", + "type": "object" + }, + "tags": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectorPolicyTags" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "version", + "revision", + "issued_at_ms", + "mode", + "connectors", + "tools" + ], + "title": "ConnectorPolicyEffectiveAllow", + "type": "object" + }, + "ConnectorPolicyEffectiveDeny": { + "additionalProperties": false, + "properties": { + "version": { + "const": 1, + "title": "Version", + "type": "integer" + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "issued_at_ms": { + "title": "Issued At Ms", + "type": "integer" + }, + "mode": { + "const": "deny", + "title": "Mode", + "type": "string" + }, + "disabled_connectors": { + "items": { + "type": "string" + }, + "title": "Disabled Connectors", + "type": "array" + }, + "tools": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Tools", + "type": "object" + }, + "tags": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectorPolicyTags" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "version", + "revision", + "issued_at_ms", + "mode", + "disabled_connectors", + "tools" + ], + "title": "ConnectorPolicyEffectiveDeny", + "type": "object" + }, + "ConnectorPolicyEffectiveDenyAll": { + "additionalProperties": false, + "properties": { + "version": { + "const": 1, + "title": "Version", + "type": "integer" + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "issued_at_ms": { + "title": "Issued At Ms", + "type": "integer" + }, + "mode": { + "const": "deny-all", + "title": "Mode", + "type": "string" + } + }, + "required": [ + "version", + "revision", + "issued_at_ms", + "mode" + ], + "title": "ConnectorPolicyEffectiveDenyAll", + "type": "object" + }, + "ConnectorPolicyEffectiveUnrestricted": { + "additionalProperties": false, + "properties": { + "version": { + "const": 1, + "title": "Version", + "type": "integer" + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "issued_at_ms": { + "title": "Issued At Ms", + "type": "integer" + }, + "mode": { + "const": "unrestricted", + "title": "Mode", + "type": "string" + } + }, + "required": [ + "version", + "revision", + "issued_at_ms", + "mode" + ], + "title": "ConnectorPolicyEffectiveUnrestricted", + "type": "object" + }, + "ConnectorPolicyGetResult": { + "additionalProperties": false, + "properties": { + "layers": { + "items": { + "$ref": "#/components/schemas/ConnectorPolicyLayer" + }, + "title": "Layers", + "type": "array" + }, + "effective": { + "discriminator": { + "mapping": { + "allow": "#/components/schemas/ConnectorPolicyEffectiveAllow", + "deny": "#/components/schemas/ConnectorPolicyEffectiveDeny", + "deny-all": "#/components/schemas/ConnectorPolicyEffectiveDenyAll", + "unrestricted": "#/components/schemas/ConnectorPolicyEffectiveUnrestricted" + }, + "propertyName": "mode" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveUnrestricted" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveDenyAll" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveAllow" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveDeny" + } + ], + "title": "Effective" + } + }, + "required": [ + "layers", + "effective" + ], + "title": "ConnectorPolicyGetResult", + "type": "object" + }, + "ConnectorPolicyLayer": { + "additionalProperties": false, + "properties": { + "kind": { + "$ref": "#/components/schemas/ConnectorPolicyLayerKind" + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "body": { + "discriminator": { + "mapping": { + "allow": "#/components/schemas/ConnectorPolicyAllowBody", + "deny": "#/components/schemas/ConnectorPolicyDenyBody", + "deny-all": "#/components/schemas/ConnectorPolicyDenyAllBody", + "unrestricted": "#/components/schemas/ConnectorPolicyUnrestrictedBody" + }, + "propertyName": "mode" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConnectorPolicyUnrestrictedBody" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyDenyAllBody" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyAllowBody" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyDenyBody" + } + ], + "title": "Body" + } + }, + "required": [ + "kind", + "revision", + "body" + ], + "title": "ConnectorPolicyLayer", + "type": "object" + }, + "ConnectorPolicyLayerKind": { + "enum": [ + "org", + "role", + "member" + ], + "title": "ConnectorPolicyLayerKind", + "type": "string" + }, + "ConnectorPolicySetParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "change": { + "discriminator": { + "mapping": { + "connector": "#/components/schemas/ConnectorChange", + "tools": "#/components/schemas/ToolsChange" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ToolsChange" + }, + { + "$ref": "#/components/schemas/ConnectorChange" + } + ], + "title": "Change" + }, + "expected_revision": { + "pattern": "^[0-7][0-9A-HJKMNP-TV-Z]{25}$", + "title": "Expected Revision", + "type": "string" + } + }, + "required": [ + "change", + "expected_revision" + ], + "title": "ConnectorPolicySetParams", + "type": "object" + }, + "ConnectorPolicySetResult": { + "additionalProperties": false, + "properties": { + "revision": { + "title": "Revision", + "type": "string" + }, + "effective": { + "discriminator": { + "mapping": { + "allow": "#/components/schemas/ConnectorPolicyEffectiveAllow", + "deny": "#/components/schemas/ConnectorPolicyEffectiveDeny", + "deny-all": "#/components/schemas/ConnectorPolicyEffectiveDenyAll", + "unrestricted": "#/components/schemas/ConnectorPolicyEffectiveUnrestricted" + }, + "propertyName": "mode" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveUnrestricted" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveDenyAll" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveAllow" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveDeny" + } + ], + "title": "Effective" + } + }, + "required": [ + "revision", + "effective" + ], + "title": "ConnectorPolicySetResult", + "type": "object" + }, + "ConnectorPolicyTags": { + "additionalProperties": false, + "properties": { + "enable": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enable" + }, + "disable": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Disable" + } + }, + "title": "ConnectorPolicyTags", + "type": "object" + }, + "ConnectorPolicyUnrestrictedBody": { + "additionalProperties": false, + "properties": { + "mode": { + "const": "unrestricted", + "title": "Mode", + "type": "string" + } + }, + "required": [ + "mode" + ], + "title": "ConnectorPolicyUnrestrictedBody", + "type": "object" + }, + "ConnectorRow": { + "additionalProperties": false, + "properties": { + "connector": { + "title": "Connector", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "connected": { + "title": "Connected", + "type": "boolean" + }, + "connection_status": { + "anyOf": [ + { + "enum": [ + "pending", + "active", + "failed", + "expired", + "revoked", + "inactive" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Connection Status" + }, + "status_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status Reason" + }, + "gateway_disabled_tools": { + "items": { + "type": "string" + }, + "title": "Gateway Disabled Tools", + "type": "array" + } + }, + "required": [ + "connector", + "enabled", + "connected", + "connection_status", + "status_reason", + "gateway_disabled_tools" + ], + "title": "ConnectorRow", + "type": "object" + }, + "ConnectorToolFacet": { + "enum": [ + "read", + "write", + "destructive", + "unclassified" + ], + "title": "ConnectorToolFacet", + "type": "string" + }, + "ConnectorToolRow": { + "additionalProperties": false, + "properties": { + "slug": { + "title": "Slug", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "facet": { + "$ref": "#/components/schemas/ConnectorToolFacet" + }, + "hints": { + "items": { + "type": "string" + }, + "title": "Hints", + "type": "array" + }, + "categories": { + "items": { + "type": "string" + }, + "title": "Categories", + "type": "array" + }, + "no_auth": { + "title": "No Auth", + "type": "boolean" + }, + "deprecated": { + "title": "Deprecated", + "type": "boolean" + } + }, + "required": [ + "slug", + "name", + "description", + "facet", + "hints", + "categories", + "no_auth", + "deprecated" + ], + "title": "ConnectorToolRow", + "type": "object" + }, + "ConnectorToolsParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "refresh": { + "default": false, + "title": "Refresh", + "type": "boolean" + } + }, + "required": [ + "slug" + ], + "title": "ConnectorToolsParams", + "type": "object" + }, + "ConnectorToolsResult": { + "additionalProperties": false, + "properties": { + "connector": { + "title": "Connector", + "type": "string" + }, + "toolkit_version": { + "title": "Toolkit Version", + "type": "string" + }, + "etag": { + "title": "Etag", + "type": "string" + }, + "fetched_at": { + "title": "Fetched At", + "type": "number" + }, + "source": { + "$ref": "#/components/schemas/ConnectorToolsSource" + }, + "stale": { + "title": "Stale", + "type": "boolean" + }, + "tools": { + "items": { + "$ref": "#/components/schemas/ConnectorToolRow" + }, + "title": "Tools", + "type": "array" + } + }, + "required": [ + "connector", + "toolkit_version", + "etag", + "fetched_at", + "source", + "stale", + "tools" + ], + "title": "ConnectorToolsResult", + "type": "object" + }, + "ConnectorToolsSource": { + "enum": [ + "cache", + "network", + "revalidated" + ], + "title": "ConnectorToolsSource", + "type": "string" + }, + "ConnectorsCatalogResult": { + "additionalProperties": false, + "properties": { + "connectors": { + "items": { + "$ref": "#/components/schemas/ConnectorCatalogRow" + }, + "title": "Connectors", + "type": "array" + } + }, + "required": [ + "connectors" + ], + "title": "ConnectorsCatalogResult", + "type": "object" + }, + "ConnectorsConnectParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "owner": { + "discriminator": { + "mapping": { + "account": "#/components/schemas/AccountOwner", + "session": "#/components/schemas/SessionOwner" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SessionOwner" + }, + { + "$ref": "#/components/schemas/AccountOwner" + } + ], + "title": "Owner" + }, + "connectors": { + "items": { + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "type": "string" + }, + "minItems": 1, + "title": "Connectors", + "type": "array" + }, + "reconnect": { + "default": false, + "title": "Reconnect", + "type": "boolean" + } + }, + "required": [ + "owner", + "connectors" + ], + "title": "ConnectorsConnectParams", + "type": "object" + }, + "ConnectorsConnectResult": { + "additionalProperties": false, + "description": "``methods_connectors._reissue`` / ``managed._off_desktop_result``: the operation the connect opened; ``status``/``note`` ride along from the tool result.", + "properties": { + "op_id": { + "title": "Op Id", + "type": "string" + }, + "seq": { + "title": "Seq", + "type": "integer" + }, + "deadline_at": { + "title": "Deadline At", + "type": "number" + }, + "settled": { + "title": "Settled", + "type": "boolean" + }, + "settled_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Settled At" + }, + "settled_by": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionSettleReason" + }, + { + "type": "null" + } + ], + "default": null + }, + "targets": { + "items": { + "$ref": "#/components/schemas/ConnectionOperationTarget" + }, + "title": "Targets", + "type": "array" + }, + "status": { + "anyOf": [ + { + "enum": [ + "initiated", + "settled" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Note" + } + }, + "required": [ + "op_id", + "seq", + "deadline_at", + "settled", + "targets" + ], + "title": "ConnectorsConnectResult", + "type": "object" + }, + "ConnectorsListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "owner": { + "discriminator": { + "mapping": { + "account": "#/components/schemas/AccountOwner", + "session": "#/components/schemas/SessionOwner" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SessionOwner" + }, + { + "$ref": "#/components/schemas/AccountOwner" + } + ], + "title": "Owner" + } + }, + "required": [ + "owner" + ], + "title": "ConnectorsListParams", + "type": "object" + }, + "ConnectorsListResult": { + "additionalProperties": false, + "properties": { + "available": { + "title": "Available", + "type": "boolean" + }, + "connectors": { + "items": { + "$ref": "#/components/schemas/ConnectorRow" + }, + "title": "Connectors", + "type": "array" + } + }, + "required": [ + "available", + "connectors" + ], + "title": "ConnectorsListResult", + "type": "object" + }, + "ContextCategory": { + "additionalProperties": false, + "properties": { + "color": { + "title": "Color", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "tokens": { + "title": "Tokens", + "type": "integer" + } + }, + "required": [ + "color", + "id", + "label", + "tokens" + ], + "title": "ContextCategory", + "type": "object" + }, + "ContextFileSource": { + "additionalProperties": false, + "description": "One row of ``agent.context_file_sources.list_context_file_sources``.", + "properties": { + "label": { + "title": "Label", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "chars": { + "title": "Chars", + "type": "integer" + }, + "est_tokens": { + "title": "Est Tokens", + "type": "integer" + }, + "loaded": { + "title": "Loaded", + "type": "boolean" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "label", + "path", + "chars", + "est_tokens", + "loaded", + "status" + ], + "title": "ContextFileSource", + "type": "object" + }, + "ControllerScope": { + "additionalProperties": false, + "properties": { + "principal_id": { + "title": "Principal Id", + "type": "string" + }, + "profile_id": { + "title": "Profile Id", + "type": "string" + }, + "session_id": { + "title": "Session Id", + "type": "string" + }, + "controller_id": { + "title": "Controller Id", + "type": "string" + }, + "browser_profile_id": { + "title": "Browser Profile Id", + "type": "string" + }, + "transport_family": { + "title": "Transport Family", + "type": "string" + }, + "capabilities": { + "items": { + "type": "string" + }, + "title": "Capabilities", + "type": "array" + } + }, + "required": [ + "principal_id", + "profile_id", + "session_id", + "controller_id", + "browser_profile_id", + "transport_family", + "capabilities" + ], + "title": "ControllerScope", + "type": "object" + }, + "CorrectionStatus": { + "enum": [ + "queued", + "redirected", + "rejected" + ], + "title": "CorrectionStatus", + "type": "string" + }, + "CronAction": { + "enum": [ + "list", + "add", + "remove", + "pause", + "resume" + ], + "title": "CronAction", + "type": "string" + }, + "CronJobRow": { + "additionalProperties": true, + "description": "``tools/cronjob_job_args.py::_format_job``.", + "properties": { + "job_id": { + "title": "Job Id", + "type": "string" + }, + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "skill": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skill" + }, + "skills": { + "items": { + "type": "string" + }, + "title": "Skills", + "type": "array" + }, + "prompt_preview": { + "default": "", + "title": "Prompt Preview", + "type": "string" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Base Url" + }, + "schedule": { + "default": "?", + "title": "Schedule", + "type": "string" + }, + "repeat": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repeat" + }, + "deliver": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Deliver" + }, + "next_run_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Next Run At" + }, + "last_run_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Run At" + }, + "last_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Status" + }, + "last_delivery_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Delivery Error" + }, + "last_delivery_unverified": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Delivery Unverified" + }, + "last_fire_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Fire Error" + }, + "last_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Error" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "State" + }, + "paused_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Paused At" + }, + "paused_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Paused Reason" + }, + "workdir": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Workdir" + }, + "script": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Script" + }, + "reasoning_effort": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reasoning Effort" + }, + "monitor_script": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Monitor Script" + }, + "monitor_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Monitor Url" + }, + "monitor_state": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Monitor State" + }, + "no_agent": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "No Agent" + }, + "enabled_toolsets": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled Toolsets" + }, + "continuity": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Continuity" + }, + "context_from": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context From" + }, + "attach_to_session": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Attach To Session" + } + }, + "required": [ + "job_id" + ], + "title": "CronJobRow", + "type": "object" + }, + "CronManageParams": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/CronAction", + "default": "list" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "include_disabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Disabled" + }, + "schedule": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Schedule" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompt" + }, + "repeat": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repeat" + }, + "continuity": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Continuity" + }, + "deliver": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Deliver" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "CronManageParams", + "type": "object" + }, + "CronManageResult": { + "additionalProperties": true, + "description": "Pass-through of ``tools/cronjob_tools.py::cronjob`` JSON: ``list`` \u2192 ``jobs``/``count``\n(+ ``scoped`` when profile-scoped); ``add`` \u2192 the created job's summary + ``job``; ``remove`` \u2192\n``removed_job``; ``pause``/``resume`` \u2192 ``job``. A tool-level failure lands in ``error``.", + "properties": { + "success": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Success" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + }, + "jobs": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/CronJobRow" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Jobs" + }, + "scoped": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scoped" + }, + "gateway_running": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Gateway Running" + }, + "warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Warning" + }, + "job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Job Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "skill": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skill" + }, + "skills": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skills" + }, + "schedule": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Schedule" + }, + "repeat": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repeat" + }, + "deliver": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Deliver" + }, + "next_run_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Next Run At" + }, + "job": { + "anyOf": [ + { + "$ref": "#/components/schemas/CronJobRow" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "guidance": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Guidance" + }, + "removed_job": { + "anyOf": [ + { + "$ref": "#/components/schemas/CronRemovedJob" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "CronManageResult", + "type": "object" + }, + "CronRemovedJob": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "schedule": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Schedule" + } + }, + "required": [ + "id" + ], + "title": "CronRemovedJob", + "type": "object" + }, + "CurrentSubscription": { + "additionalProperties": false, + "properties": { + "tier_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tier Id" + }, + "tier_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tier Name" + }, + "monthly_credits": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Monthly Credits" + }, + "credits_remaining": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Credits Remaining" + }, + "cycle_ends_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cycle Ends At" + }, + "pending_downgrade_tier_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pending Downgrade Tier Name" + }, + "pending_downgrade_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pending Downgrade At" + }, + "pending_downgrade_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pending Downgrade Display" + }, + "cancel_at_period_end": { + "title": "Cancel At Period End", + "type": "boolean" + }, + "cancellation_effective_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cancellation Effective At" + }, + "cancellation_effective_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cancellation Effective Display" + } + }, + "required": [ + "cancel_at_period_end" + ], + "title": "CurrentSubscription", + "type": "object" + }, + "DelegationPauseParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "paused": { + "default": true, + "title": "Paused", + "type": "boolean" + } + }, + "title": "DelegationPauseParams", + "type": "object" + }, + "DelegationPauseResult": { + "additionalProperties": false, + "properties": { + "paused": { + "title": "Paused", + "type": "boolean" + } + }, + "required": [ + "paused" + ], + "title": "DelegationPauseResult", + "type": "object" + }, + "DelegationStatusResult": { + "additionalProperties": false, + "properties": { + "active": { + "items": { + "$ref": "#/components/schemas/ActiveSubagent" + }, + "title": "Active", + "type": "array" + }, + "paused": { + "title": "Paused", + "type": "boolean" + }, + "max_spawn_depth": { + "title": "Max Spawn Depth", + "type": "integer" + }, + "max_concurrent_children": { + "title": "Max Concurrent Children", + "type": "integer" + } + }, + "required": [ + "active", + "paused", + "max_spawn_depth", + "max_concurrent_children" + ], + "title": "DelegationStatusResult", + "type": "object" + }, + "DiagnosticsShareNousParams": { + "additionalProperties": false, + "properties": { + "error_context": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Context" + }, + "extra_files": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Extra Files" + }, + "log_lines": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Lines" + } + }, + "title": "DiagnosticsShareNousParams", + "type": "object" + }, + "DiagnosticsShareNousResult": { + "additionalProperties": false, + "description": "Structured envelope: ``ok=False`` + ``error`` renders inline instead of failing the RPC.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "view_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "View Url" + }, + "upload_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Upload Id" + }, + "expires_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expires At" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + } + }, + "required": [ + "ok" + ], + "title": "DiagnosticsShareNousResult", + "type": "object" + }, + "DiscoveredRepo": { + "additionalProperties": false, + "description": "``methods_projects._discover_repos_payload`` row: a git root with session totals.", + "properties": { + "root": { + "title": "Root", + "type": "string" + }, + "label": { + "default": "", + "title": "Label", + "type": "string" + }, + "sessions": { + "default": 0, + "title": "Sessions", + "type": "integer" + }, + "last_active": { + "default": 0.0, + "title": "Last Active", + "type": "number" + } + }, + "required": [ + "root" + ], + "title": "DiscoveredRepo", + "type": "object" + }, + "DispatchType": { + "description": "``apps/shared/src/slash.ts::parseCommandDispatch`` branches on this.", + "enum": [ + "exec", + "alias", + "plugin", + "send", + "skill", + "prefill" + ], + "title": "DispatchType", + "type": "string" + }, + "DisplayImageSwitch": { + "additionalProperties": false, + "description": "A persisted Docker sandbox kept on the previous default image; the user decides the switch.", + "properties": { + "current_image": { + "title": "Current Image", + "type": "string" + }, + "target_image": { + "title": "Target Image", + "type": "string" + }, + "containers": { + "title": "Containers", + "type": "integer" + } + }, + "required": [ + "current_image", + "target_image", + "containers" + ], + "title": "DisplayImageSwitch", + "type": "object" + }, + "DisplayInstallDonePayload": { + "additionalProperties": false, + "properties": { + "profile_key": { + "title": "Profile Key", + "type": "string" + }, + "code": { + "title": "Code", + "type": "integer" + }, + "status": { + "$ref": "#/components/schemas/DisplayStatus" + } + }, + "required": [ + "profile_key", + "code", + "status" + ], + "title": "DisplayInstallDonePayload", + "type": "object" + }, + "DisplayInstallLogPayload": { + "additionalProperties": false, + "properties": { + "profile_key": { + "title": "Profile Key", + "type": "string" + }, + "line": { + "title": "Line", + "type": "string" + } + }, + "required": [ + "profile_key", + "line" + ], + "title": "DisplayInstallLogPayload", + "type": "object" + }, + "DisplayInstallResult": { + "additionalProperties": false, + "properties": { + "started": { + "title": "Started", + "type": "boolean" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "profile_key": { + "title": "Profile Key", + "type": "string" + } + }, + "required": [ + "started", + "profile_key" + ], + "title": "DisplayInstallResult", + "type": "object" + }, + "DisplayInstallSudoParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile_key": { + "title": "Profile Key", + "type": "string" + } + }, + "required": [ + "session_id", + "profile_key" + ], + "title": "DisplayInstallSudoParams", + "type": "object" + }, + "DisplayLease": { + "additionalProperties": false, + "description": "``tools/bot_desktop/lease.py::Lease`` as clients may see it: the holder's viewer id is a\ncapability and never leaves the gateway; ``viewer_hash`` lets the holder recognise itself.", + "properties": { + "holder": { + "$ref": "#/components/schemas/LeaseHolder" + }, + "viewer_id": { + "default": null, + "title": "Viewer Id", + "type": "null" + }, + "viewer_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Viewer Hash" + }, + "since": { + "title": "Since", + "type": "number" + }, + "epoch": { + "title": "Epoch", + "type": "integer" + }, + "reason": { + "default": "", + "title": "Reason", + "type": "string" + } + }, + "required": [ + "holder", + "since", + "epoch" + ], + "title": "DisplayLease", + "type": "object" + }, + "DisplayLeaseAcquireParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "viewer_id": { + "title": "Viewer Id", + "type": "string" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "viewer_id" + ], + "title": "DisplayLeaseAcquireParams", + "type": "object" + }, + "DisplayLeasePayload": { + "additionalProperties": false, + "properties": { + "profile_key": { + "title": "Profile Key", + "type": "string" + }, + "lease": { + "$ref": "#/components/schemas/DisplayLease" + } + }, + "required": [ + "profile_key", + "lease" + ], + "title": "DisplayLeasePayload", + "type": "object" + }, + "DisplayLeaseReleaseParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "viewer_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Viewer Id" + }, + "force": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Force" + } + }, + "title": "DisplayLeaseReleaseParams", + "type": "object" + }, + "DisplayLeaseResult": { + "additionalProperties": false, + "properties": { + "lease": { + "$ref": "#/components/schemas/DisplayLease" + } + }, + "required": [ + "lease" + ], + "title": "DisplayLeaseResult", + "type": "object" + }, + "DisplayObserveParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "viewer_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Viewer Id" + } + }, + "title": "DisplayObserveParams", + "type": "object" + }, + "DisplayObserveResult": { + "additionalProperties": false, + "properties": { + "profile": { + "title": "Profile", + "type": "string" + }, + "supported": { + "title": "Supported", + "type": "boolean" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "missing": { + "items": { + "type": "string" + }, + "title": "Missing", + "type": "array" + }, + "running": { + "title": "Running", + "type": "boolean" + }, + "pid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pid" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "socket": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Socket" + }, + "geometry": { + "title": "Geometry", + "type": "string" + }, + "install_command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Install Command" + }, + "browser": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Browser" + }, + "blocker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Blocker" + }, + "memory_available_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Available Mb" + }, + "memory_limit_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Limit Mb" + }, + "placement": { + "default": "gateway", + "title": "Placement", + "type": "string" + }, + "image_switch": { + "anyOf": [ + { + "$ref": "#/components/schemas/DisplayImageSwitch" + }, + { + "type": "null" + } + ], + "default": null + }, + "lease": { + "$ref": "#/components/schemas/DisplayLease" + }, + "profile_key": { + "title": "Profile Key", + "type": "string" + }, + "ticket": { + "title": "Ticket", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "viewer_id": { + "title": "Viewer Id", + "type": "string" + } + }, + "required": [ + "profile", + "supported", + "installed", + "missing", + "running", + "geometry", + "lease", + "profile_key", + "ticket", + "path", + "viewer_id" + ], + "title": "DisplayObserveResult", + "type": "object" + }, + "DisplayStatus": { + "additionalProperties": false, + "description": "``tools/bot_desktop/runtime.py::DesktopStatus`` plus the lease and the profile it speaks for.", + "properties": { + "profile": { + "title": "Profile", + "type": "string" + }, + "supported": { + "title": "Supported", + "type": "boolean" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "missing": { + "items": { + "type": "string" + }, + "title": "Missing", + "type": "array" + }, + "running": { + "title": "Running", + "type": "boolean" + }, + "pid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pid" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "socket": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Socket" + }, + "geometry": { + "title": "Geometry", + "type": "string" + }, + "install_command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Install Command" + }, + "browser": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Browser" + }, + "blocker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Blocker" + }, + "memory_available_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Available Mb" + }, + "memory_limit_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Limit Mb" + }, + "placement": { + "default": "gateway", + "title": "Placement", + "type": "string" + }, + "image_switch": { + "anyOf": [ + { + "$ref": "#/components/schemas/DisplayImageSwitch" + }, + { + "type": "null" + } + ], + "default": null + }, + "lease": { + "$ref": "#/components/schemas/DisplayLease" + }, + "profile_key": { + "title": "Profile Key", + "type": "string" + } + }, + "required": [ + "profile", + "supported", + "installed", + "missing", + "running", + "geometry", + "lease", + "profile_key" + ], + "title": "DisplayStatus", + "type": "object" + }, + "DisplayStatusPayload": { + "additionalProperties": false, + "properties": { + "profile": { + "title": "Profile", + "type": "string" + }, + "supported": { + "title": "Supported", + "type": "boolean" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "missing": { + "items": { + "type": "string" + }, + "title": "Missing", + "type": "array" + }, + "running": { + "title": "Running", + "type": "boolean" + }, + "pid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pid" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "socket": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Socket" + }, + "geometry": { + "title": "Geometry", + "type": "string" + }, + "install_command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Install Command" + }, + "browser": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Browser" + }, + "blocker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Blocker" + }, + "memory_available_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Available Mb" + }, + "memory_limit_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Limit Mb" + }, + "placement": { + "default": "gateway", + "title": "Placement", + "type": "string" + }, + "image_switch": { + "anyOf": [ + { + "$ref": "#/components/schemas/DisplayImageSwitch" + }, + { + "type": "null" + } + ], + "default": null + }, + "lease": { + "$ref": "#/components/schemas/DisplayLease" + }, + "profile_key": { + "title": "Profile Key", + "type": "string" + } + }, + "required": [ + "profile", + "supported", + "installed", + "missing", + "running", + "geometry", + "lease", + "profile_key" + ], + "title": "DisplayStatusPayload", + "type": "object" + }, + "DisplayStopParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "force": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Force" + } + }, + "title": "DisplayStopParams", + "type": "object" + }, + "DisplayStopResult": { + "additionalProperties": false, + "properties": { + "profile": { + "title": "Profile", + "type": "string" + }, + "supported": { + "title": "Supported", + "type": "boolean" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "missing": { + "items": { + "type": "string" + }, + "title": "Missing", + "type": "array" + }, + "running": { + "title": "Running", + "type": "boolean" + }, + "pid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pid" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "socket": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Socket" + }, + "geometry": { + "title": "Geometry", + "type": "string" + }, + "install_command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Install Command" + }, + "browser": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Browser" + }, + "blocker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Blocker" + }, + "memory_available_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Available Mb" + }, + "memory_limit_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Limit Mb" + }, + "placement": { + "default": "gateway", + "title": "Placement", + "type": "string" + }, + "image_switch": { + "anyOf": [ + { + "$ref": "#/components/schemas/DisplayImageSwitch" + }, + { + "type": "null" + } + ], + "default": null + }, + "lease": { + "$ref": "#/components/schemas/DisplayLease" + }, + "profile_key": { + "title": "Profile Key", + "type": "string" + }, + "stopped": { + "title": "Stopped", + "type": "boolean" + } + }, + "required": [ + "profile", + "supported", + "installed", + "missing", + "running", + "geometry", + "lease", + "profile_key", + "stopped" + ], + "title": "DisplayStopResult", + "type": "object" + }, + "DisplaySwitchSandboxImageParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "approve": { + "default": true, + "title": "Approve", + "type": "boolean" + } + }, + "title": "DisplaySwitchSandboxImageParams", + "type": "object" + }, + "DisplaySwitchSandboxImageResult": { + "additionalProperties": false, + "properties": { + "profile": { + "title": "Profile", + "type": "string" + }, + "supported": { + "title": "Supported", + "type": "boolean" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "missing": { + "items": { + "type": "string" + }, + "title": "Missing", + "type": "array" + }, + "running": { + "title": "Running", + "type": "boolean" + }, + "pid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pid" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "socket": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Socket" + }, + "geometry": { + "title": "Geometry", + "type": "string" + }, + "install_command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Install Command" + }, + "browser": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Browser" + }, + "blocker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Blocker" + }, + "memory_available_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Available Mb" + }, + "memory_limit_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Limit Mb" + }, + "placement": { + "default": "gateway", + "title": "Placement", + "type": "string" + }, + "image_switch": { + "anyOf": [ + { + "$ref": "#/components/schemas/DisplayImageSwitch" + }, + { + "type": "null" + } + ], + "default": null + }, + "lease": { + "$ref": "#/components/schemas/DisplayLease" + }, + "profile_key": { + "title": "Profile Key", + "type": "string" + }, + "docker_image": { + "title": "Docker Image", + "type": "string" + } + }, + "required": [ + "profile", + "supported", + "installed", + "missing", + "running", + "geometry", + "lease", + "profile_key", + "docker_image" + ], + "title": "DisplaySwitchSandboxImageResult", + "type": "object" + }, + "DisplayThumbnailResult": { + "additionalProperties": false, + "description": "``data_url`` is null while the screen is stopped or while a human holds the lease\n(``suppressed``): the frame may show what they are typing.", + "properties": { + "data_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data Url" + }, + "suppressed": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Suppressed" + } + }, + "title": "DisplayThumbnailResult", + "type": "object" + }, + "EmptyRequestParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + } + }, + "required": [ + "session_id" + ], + "title": "EmptyRequestParams", + "type": "object" + }, + "ErrorPayload": { + "additionalProperties": false, + "description": "Every ``_emit(\"error\", \u2026)`` site sets exactly ``message``.", + "properties": { + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "message" + ], + "title": "ErrorPayload", + "type": "object" + }, + "ErrorSurface": { + "additionalProperties": true, + "description": "``agent/error_surface.py::_surface`` \u2014 advisory {layer, code, retryable} (+ identity, + auth hint,\n+ ``resets_at`` epoch seconds when the provider named when its limit lifts).", + "properties": { + "layer": { + "title": "Layer", + "type": "string" + }, + "code": { + "title": "Code", + "type": "string" + }, + "retryable": { + "title": "Retryable", + "type": "boolean" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "resets_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resets At" + } + }, + "required": [ + "layer", + "code", + "retryable" + ], + "title": "ErrorSurface", + "type": "object" + }, + "FailedDelegation": { + "additionalProperties": false, + "description": "``async_delegation.failed_delegations_for_session`` row: one failed task of an async delegation.", + "properties": { + "delegation_id": { + "title": "Delegation Id", + "type": "string" + }, + "task_index": { + "default": 0, + "title": "Task Index", + "type": "integer" + }, + "status": { + "title": "Status", + "type": "string" + }, + "goal": { + "default": "", + "title": "Goal", + "type": "string" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "dispatched_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dispatched At" + }, + "completed_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Completed At" + } + }, + "required": [ + "delegation_id", + "status" + ], + "title": "FailedDelegation", + "type": "object" + }, + "FileAttachParams": { + "additionalProperties": false, + "description": "``path`` when the file is gateway-visible, else ``data_url`` carries the bytes; ``name`` labels\nan uploaded file.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "data_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data Url" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + } + }, + "required": [ + "session_id" + ], + "title": "FileAttachParams", + "type": "object" + }, + "FileAttachResult": { + "additionalProperties": false, + "properties": { + "attached": { + "title": "Attached", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "ref_path": { + "title": "Ref Path", + "type": "string" + }, + "ref_text": { + "title": "Ref Text", + "type": "string" + }, + "uploaded": { + "title": "Uploaded", + "type": "boolean" + } + }, + "required": [ + "attached", + "name", + "path", + "ref_path", + "ref_text", + "uploaded" + ], + "title": "FileAttachResult", + "type": "object" + }, + "ForeignSessionRow": { + "additionalProperties": false, + "description": "``hermes_cli/foreign_sessions_browser.py::list_foreign_sessions`` \u2014 ``id`` is an opaque\nhandle, never a path.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/ForeignSource" + }, + "label": { + "title": "Label", + "type": "string" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "mtime": { + "title": "Mtime", + "type": "number" + }, + "turn_count": { + "default": 0, + "title": "Turn Count", + "type": "integer" + }, + "excerpt": { + "default": "", + "title": "Excerpt", + "type": "string" + } + }, + "required": [ + "id", + "source", + "label", + "mtime" + ], + "title": "ForeignSessionRow", + "type": "object" + }, + "ForeignSource": { + "enum": [ + "claude", + "codex" + ], + "title": "ForeignSource", + "type": "string" + }, + "ForeignTurn": { + "additionalProperties": false, + "properties": { + "role": { + "title": "Role", + "type": "string" + }, + "content": { + "title": "Content", + "type": "string" + } + }, + "required": [ + "role", + "content" + ], + "title": "ForeignTurn", + "type": "object" + }, + "FreeTierAckNoticeResult": { + "additionalProperties": false, + "properties": { + "acked": { + "title": "Acked", + "type": "boolean" + } + }, + "required": [ + "acked" + ], + "title": "FreeTierAckNoticeResult", + "type": "object" + }, + "FreeTierProvisionResult": { + "additionalProperties": false, + "properties": { + "has_guest": { + "title": "Has Guest", + "type": "boolean" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Code" + }, + "retryable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retryable" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + } + }, + "required": [ + "has_guest", + "enabled" + ], + "title": "FreeTierProvisionResult", + "type": "object" + }, + "FreeTierStatusResult": { + "additionalProperties": false, + "description": "``available`` = an identity exists AND the tier is on; whether inference runs on it is\n``setup.runtime_check.free_tier_route``'s question.", + "properties": { + "has_guest": { + "title": "Has Guest", + "type": "boolean" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "available": { + "title": "Available", + "type": "boolean" + }, + "notice_pending": { + "title": "Notice Pending", + "type": "boolean" + }, + "model": { + "title": "Model", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Code" + }, + "retryable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retryable" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + } + }, + "required": [ + "has_guest", + "enabled", + "available", + "notice_pending", + "model", + "label" + ], + "title": "FreeTierStatusResult", + "type": "object" + }, + "GatewayCapabilitiesResult": { + "additionalProperties": false, + "properties": { + "per_session_exclusive_submit": { + "title": "Per Session Exclusive Submit", + "type": "boolean" + } + }, + "required": [ + "per_session_exclusive_submit" + ], + "title": "GatewayCapabilitiesResult", + "type": "object" + }, + "GatewayReadyPayload": { + "additionalProperties": false, + "description": "``tui_gateway/entry.py`` (stdio) / ``tui_gateway/ws.py`` (WebSocket) first frame.", + "properties": { + "skin": { + "$ref": "#/components/schemas/SkinPayload" + }, + "change_events": { + "title": "Change Events", + "type": "boolean" + }, + "replay_epoch": { + "title": "Replay Epoch", + "type": "string" + }, + "heartbeat": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Heartbeat" + } + }, + "required": [ + "skin", + "change_events", + "replay_epoch" + ], + "title": "GatewayReadyPayload", + "type": "object" + }, + "GoalContractSnapshot": { + "additionalProperties": false, + "description": "``hermes_cli/goals.py::GoalContract.to_dict``.", + "properties": { + "outcome": { + "default": "", + "title": "Outcome", + "type": "string" + }, + "verification": { + "default": "", + "title": "Verification", + "type": "string" + }, + "constraints": { + "default": "", + "title": "Constraints", + "type": "string" + }, + "boundaries": { + "default": "", + "title": "Boundaries", + "type": "string" + }, + "stop_when": { + "default": "", + "title": "Stop When", + "type": "string" + } + }, + "title": "GoalContractSnapshot", + "type": "object" + }, + "GoalGateSnapshot": { + "additionalProperties": false, + "properties": { + "command": { + "title": "Command", + "type": "string" + }, + "timeout_seconds": { + "title": "Timeout Seconds", + "type": "integer" + }, + "max_retries": { + "title": "Max Retries", + "type": "integer" + }, + "attempts": { + "title": "Attempts", + "type": "integer" + }, + "last_exit_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Exit Code" + } + }, + "required": [ + "command", + "timeout_seconds", + "max_retries", + "attempts" + ], + "title": "GoalGateSnapshot", + "type": "object" + }, + "GoalSnapshot": { + "additionalProperties": false, + "description": "``methods_session_control.py::_safe_goal_snapshot`` \u2014 the frontend-safe GoalState subset.", + "properties": { + "title": { + "title": "Title", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "turns_used": { + "title": "Turns Used", + "type": "integer" + }, + "max_turns": { + "title": "Max Turns", + "type": "integer" + }, + "contract": { + "$ref": "#/components/schemas/GoalContractSnapshot" + }, + "subgoals": { + "items": { + "type": "string" + }, + "title": "Subgoals", + "type": "array" + }, + "gates": { + "items": { + "$ref": "#/components/schemas/GoalGateSnapshot" + }, + "title": "Gates", + "type": "array" + }, + "created_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Updated At" + }, + "paused_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Paused Reason" + }, + "last_verdict": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Verdict" + }, + "last_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Reason" + }, + "wait_barrier": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "pid": "#/components/schemas/WaitBarrierTarget", + "session": "#/components/schemas/WaitBarrierTarget", + "until": "#/components/schemas/WaitBarrierUntil" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/WaitBarrierUntil" + }, + { + "$ref": "#/components/schemas/WaitBarrierTarget" + } + ] + }, + { + "type": "null" + } + ], + "default": null, + "title": "Wait Barrier" + } + }, + "required": [ + "title", + "status", + "turns_used", + "max_turns", + "contract", + "subgoals", + "gates" + ], + "title": "GoalSnapshot", + "type": "object" + }, + "GroupsApproveParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "member_id": { + "title": "Member Id", + "type": "string" + }, + "task_id": { + "title": "Task Id", + "type": "string" + }, + "execution_generation": { + "title": "Execution Generation", + "type": "integer" + }, + "choice": { + "$ref": "#/components/schemas/ApprovalChoice" + }, + "request_id": { + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "room_id", + "member_id", + "task_id", + "execution_generation", + "choice", + "request_id" + ], + "title": "GroupsApproveParams", + "type": "object" + }, + "GroupsApproveResult": { + "additionalProperties": false, + "description": "``result`` is the local ``approval.respond`` answer or the peer's run-action receipt.", + "properties": { + "approved": { + "default": true, + "title": "Approved", + "type": "boolean" + }, + "result": { + "additionalProperties": true, + "title": "Result", + "type": "object" + } + }, + "required": [ + "result" + ], + "title": "GroupsApproveResult", + "type": "object" + }, + "GroupsCapabilitiesParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "GroupsCapabilitiesParams", + "type": "object" + }, + "GroupsCapabilitiesResult": { + "additionalProperties": false, + "properties": { + "protocol_version": { + "title": "Protocol Version", + "type": "integer" + }, + "driver": { + "title": "Driver", + "type": "boolean" + }, + "persistent_process": { + "title": "Persistent Process", + "type": "boolean" + }, + "authority_gateway_id": { + "title": "Authority Gateway Id", + "type": "string" + }, + "room_link": { + "$ref": "#/components/schemas/RoomLinkStatus" + }, + "features": { + "items": { + "type": "string" + }, + "title": "Features", + "type": "array" + }, + "methods": { + "items": { + "type": "string" + }, + "title": "Methods", + "type": "array" + }, + "max_log_limit": { + "title": "Max Log Limit", + "type": "integer" + } + }, + "required": [ + "protocol_version", + "driver", + "persistent_process", + "authority_gateway_id", + "room_link", + "features", + "methods", + "max_log_limit" + ], + "title": "GroupsCapabilitiesResult", + "type": "object" + }, + "GroupsCreateParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "members": { + "items": { + "$ref": "#/components/schemas/RoomMemberInput" + }, + "title": "Members", + "type": "array" + }, + "authority_gateway_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Gateway Id" + } + }, + "required": [ + "room_id", + "name", + "members" + ], + "title": "GroupsCreateParams", + "type": "object" + }, + "GroupsCreateResult": { + "additionalProperties": false, + "properties": { + "room": { + "$ref": "#/components/schemas/Room" + } + }, + "required": [ + "room" + ], + "title": "GroupsCreateResult", + "type": "object" + }, + "GroupsDemoteParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "observed_gateway_id": { + "title": "Observed Gateway Id", + "type": "string" + }, + "observed_epoch": { + "title": "Observed Epoch", + "type": "integer" + } + }, + "required": [ + "room_id", + "observed_gateway_id", + "observed_epoch" + ], + "title": "GroupsDemoteParams", + "type": "object" + }, + "GroupsDemoteResult": { + "additionalProperties": false, + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "authority_gateway_id": { + "title": "Authority Gateway Id", + "type": "string" + }, + "authority_epoch": { + "title": "Authority Epoch", + "type": "integer" + }, + "idempotent": { + "title": "Idempotent", + "type": "boolean" + } + }, + "required": [ + "room_id", + "authority_gateway_id", + "authority_epoch", + "idempotent" + ], + "title": "GroupsDemoteResult", + "type": "object" + }, + "GroupsDisbandParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "cancel_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cancel Id" + } + }, + "required": [ + "room_id" + ], + "title": "GroupsDisbandParams", + "type": "object" + }, + "GroupsDisbandResult": { + "additionalProperties": false, + "properties": { + "tombstone": { + "$ref": "#/components/schemas/RoomTombstone" + } + }, + "required": [ + "tombstone" + ], + "title": "GroupsDisbandResult", + "type": "object" + }, + "GroupsListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "include_disbanded": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Disbanded" + }, + "limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Limit" + }, + "offset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Offset" + } + }, + "title": "GroupsListParams", + "type": "object" + }, + "GroupsListResult": { + "additionalProperties": false, + "properties": { + "rooms": { + "items": { + "$ref": "#/components/schemas/Room" + }, + "title": "Rooms", + "type": "array" + }, + "next_offset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Next Offset" + } + }, + "required": [ + "rooms" + ], + "title": "GroupsListResult", + "type": "object" + }, + "GroupsLogParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "since_seq": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Since Seq" + }, + "limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Limit" + }, + "include_disbanded": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Disbanded" + } + }, + "required": [ + "room_id" + ], + "title": "GroupsLogParams", + "type": "object" + }, + "GroupsLogResult": { + "additionalProperties": false, + "description": "``gateway/hosted_rooms.py::read_events`` page \u2014 also the ``page`` ``groups.replicate`` ingests.", + "properties": { + "events": { + "items": { + "$ref": "#/components/schemas/RoomEvent" + }, + "title": "Events", + "type": "array" + }, + "cursor": { + "title": "Cursor", + "type": "integer" + }, + "latest_seq": { + "title": "Latest Seq", + "type": "integer" + }, + "has_more": { + "title": "Has More", + "type": "boolean" + }, + "authority": { + "$ref": "#/components/schemas/RoomAuthority" + } + }, + "required": [ + "events", + "cursor", + "latest_seq", + "has_more", + "authority" + ], + "title": "GroupsLogResult", + "type": "object" + }, + "GroupsPeerInviteParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Room Id" + }, + "home_install_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Home Install Id" + }, + "authority_gateway_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Gateway Id" + }, + "authority_epoch": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Epoch" + }, + "member_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Member Id" + }, + "grant_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Grant Id" + }, + "ttl_seconds": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ttl Seconds" + } + }, + "title": "GroupsPeerInviteParams", + "type": "object" + }, + "GroupsPeerInviteResult": { + "additionalProperties": false, + "properties": { + "grant": { + "title": "Grant", + "type": "string" + }, + "target_profile": { + "title": "Target Profile", + "type": "string" + }, + "catalog": { + "$ref": "#/components/schemas/RoomLinkCatalog" + }, + "endpoint": { + "$ref": "#/components/schemas/RoomLinkEndpoint" + } + }, + "required": [ + "grant", + "target_profile", + "catalog", + "endpoint" + ], + "title": "GroupsPeerInviteResult", + "type": "object" + }, + "GroupsPeerRegisterParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "member_id": { + "title": "Member Id", + "type": "string" + }, + "target_url": { + "title": "Target Url", + "type": "string" + }, + "target_profile": { + "title": "Target Profile", + "type": "string" + }, + "grant": { + "title": "Grant", + "type": "string" + }, + "catalog": { + "additionalProperties": true, + "title": "Catalog", + "type": "object" + }, + "cancellation_scope_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cancellation Scope Id" + }, + "trace_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Trace Id" + } + }, + "required": [ + "room_id", + "member_id", + "target_url", + "target_profile", + "grant", + "catalog" + ], + "title": "GroupsPeerRegisterParams", + "type": "object" + }, + "GroupsPeerRegisterResult": { + "additionalProperties": false, + "properties": { + "registered": { + "default": true, + "title": "Registered", + "type": "boolean" + }, + "mode": { + "title": "Mode", + "type": "string" + }, + "transport_security": { + "title": "Transport Security", + "type": "string" + }, + "target_install_id": { + "title": "Target Install Id", + "type": "string" + }, + "target_profile": { + "title": "Target Profile", + "type": "string" + } + }, + "required": [ + "mode", + "transport_security", + "target_install_id", + "target_profile" + ], + "title": "GroupsPeerRegisterResult", + "type": "object" + }, + "GroupsPeerRevokeParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "grant": { + "title": "Grant", + "type": "string" + } + }, + "required": [ + "grant" + ], + "title": "GroupsPeerRevokeParams", + "type": "object" + }, + "GroupsPeerRevokeResult": { + "additionalProperties": false, + "properties": { + "revoked": { + "default": true, + "title": "Revoked", + "type": "boolean" + } + }, + "title": "GroupsPeerRevokeResult", + "type": "object" + }, + "GroupsPromoteParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "confirm": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "room_id" + ], + "title": "GroupsPromoteParams", + "type": "object" + }, + "GroupsPromoteResult": { + "additionalProperties": false, + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "authority_gateway_id": { + "title": "Authority Gateway Id", + "type": "string" + }, + "authority_epoch": { + "title": "Authority Epoch", + "type": "integer" + }, + "previous_gateway_id": { + "title": "Previous Gateway Id", + "type": "string" + }, + "previous_epoch": { + "title": "Previous Epoch", + "type": "integer" + }, + "claim_seq": { + "title": "Claim Seq", + "type": "integer" + }, + "latest_seq": { + "title": "Latest Seq", + "type": "integer" + } + }, + "required": [ + "room_id", + "authority_gateway_id", + "authority_epoch", + "previous_gateway_id", + "previous_epoch", + "claim_seq", + "latest_seq" + ], + "title": "GroupsPromoteResult", + "type": "object" + }, + "GroupsRenameParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "event_id": { + "title": "Event Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "room_id", + "event_id", + "name" + ], + "title": "GroupsRenameParams", + "type": "object" + }, + "GroupsRenameResult": { + "additionalProperties": false, + "properties": { + "room": { + "$ref": "#/components/schemas/Room" + } + }, + "required": [ + "room" + ], + "title": "GroupsRenameResult", + "type": "object" + }, + "GroupsReplicaStateParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + } + }, + "required": [ + "room_id" + ], + "title": "GroupsReplicaStateParams", + "type": "object" + }, + "GroupsReplicaStateResult": { + "additionalProperties": false, + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "members": { + "items": { + "$ref": "#/components/schemas/RoomMember" + }, + "title": "Members", + "type": "array" + }, + "authority": { + "$ref": "#/components/schemas/RoomAuthority" + }, + "last_seq": { + "title": "Last Seq", + "type": "integer" + }, + "latest_seq": { + "title": "Latest Seq", + "type": "integer" + }, + "event_bytes": { + "title": "Event Bytes", + "type": "integer" + }, + "created_at": { + "title": "Created At", + "type": "number" + }, + "updated_at": { + "title": "Updated At", + "type": "number" + } + }, + "required": [ + "room_id", + "name", + "members", + "authority", + "last_seq", + "latest_seq", + "event_bytes", + "created_at", + "updated_at" + ], + "title": "GroupsReplicaStateResult", + "type": "object" + }, + "GroupsReplicateParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "room_name": { + "title": "Room Name", + "type": "string" + }, + "members": { + "items": { + "$ref": "#/components/schemas/RoomMemberInput" + }, + "title": "Members", + "type": "array" + }, + "page": { + "additionalProperties": true, + "title": "Page", + "type": "object" + } + }, + "required": [ + "room_id", + "room_name", + "members", + "page" + ], + "title": "GroupsReplicateParams", + "type": "object" + }, + "GroupsReplicateResult": { + "additionalProperties": false, + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "stored_seq": { + "title": "Stored Seq", + "type": "integer" + }, + "ingested": { + "title": "Ingested", + "type": "integer" + }, + "authority": { + "$ref": "#/components/schemas/RoomAuthority" + }, + "caught_up": { + "title": "Caught Up", + "type": "boolean" + } + }, + "required": [ + "room_id", + "stored_seq", + "ingested", + "authority", + "caught_up" + ], + "title": "GroupsReplicateResult", + "type": "object" + }, + "GroupsRetryParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "task_id": { + "title": "Task Id", + "type": "string" + } + }, + "required": [ + "room_id", + "task_id" + ], + "title": "GroupsRetryParams", + "type": "object" + }, + "GroupsRetryResult": { + "additionalProperties": false, + "properties": { + "retried": { + "default": true, + "title": "Retried", + "type": "boolean" + }, + "task": { + "$ref": "#/components/schemas/RoomTaskReceipt" + } + }, + "required": [ + "task" + ], + "title": "GroupsRetryResult", + "type": "object" + }, + "GroupsSendParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "event_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Event Id" + }, + "payload": { + "additionalProperties": true, + "title": "Payload", + "type": "object" + } + }, + "required": [ + "room_id", + "payload" + ], + "title": "GroupsSendParams", + "type": "object" + }, + "GroupsSendResult": { + "additionalProperties": false, + "properties": { + "event": { + "$ref": "#/components/schemas/RoomEvent" + }, + "client_event_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Client Event Id" + }, + "accepted": { + "default": true, + "title": "Accepted", + "type": "boolean" + }, + "driver_started": { + "default": true, + "title": "Driver Started", + "type": "boolean" + } + }, + "required": [ + "event" + ], + "title": "GroupsSendResult", + "type": "object" + }, + "GroupsStateParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "include_disbanded": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Disbanded" + } + }, + "required": [ + "room_id" + ], + "title": "GroupsStateParams", + "type": "object" + }, + "GroupsStateResult": { + "additionalProperties": false, + "properties": { + "room": { + "$ref": "#/components/schemas/Room" + }, + "driver_status": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomDriverStatus" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "room" + ], + "title": "GroupsStateResult", + "type": "object" + }, + "GroupsStopParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "cancel_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cancel Id" + } + }, + "required": [ + "room_id" + ], + "title": "GroupsStopParams", + "type": "object" + }, + "GroupsStopResult": { + "additionalProperties": false, + "properties": { + "cancelled": { + "title": "Cancelled", + "type": "integer" + } + }, + "required": [ + "cancelled" + ], + "title": "GroupsStopResult", + "type": "object" + }, + "HandoffFailParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + } + }, + "required": [ + "session_id" + ], + "title": "HandoffFailParams", + "type": "object" + }, + "HandoffFailResult": { + "additionalProperties": false, + "description": "``failed`` false when the watcher already claimed the row; ``state`` is what it is now.", + "properties": { + "failed": { + "title": "Failed", + "type": "boolean" + }, + "state": { + "title": "State", + "type": "string" + } + }, + "required": [ + "failed", + "state" + ], + "title": "HandoffFailResult", + "type": "object" + }, + "HandoffRequestParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "platform": { + "title": "Platform", + "type": "string" + } + }, + "required": [ + "session_id", + "platform" + ], + "title": "HandoffRequestParams", + "type": "object" + }, + "HandoffRequestResult": { + "additionalProperties": false, + "properties": { + "queued": { + "title": "Queued", + "type": "boolean" + }, + "session_key": { + "title": "Session Key", + "type": "string" + }, + "platform": { + "title": "Platform", + "type": "string" + }, + "home_name": { + "title": "Home Name", + "type": "string" + } + }, + "required": [ + "queued", + "session_key", + "platform", + "home_name" + ], + "title": "HandoffRequestResult", + "type": "object" + }, + "HandoffStateResult": { + "additionalProperties": false, + "description": "``state`` is pending | running | completed | failed, or '' when nothing was requested.", + "properties": { + "state": { + "title": "State", + "type": "string" + }, + "platform": { + "title": "Platform", + "type": "string" + }, + "error": { + "title": "Error", + "type": "string" + } + }, + "required": [ + "state", + "platform", + "error" + ], + "title": "HandoffStateResult", + "type": "object" + }, + "HeartbeatSnapshot": { + "additionalProperties": false, + "properties": { + "prompt": { + "title": "Prompt", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "interval_seconds": { + "title": "Interval Seconds", + "type": "integer" + }, + "created_at": { + "title": "Created At", + "type": "number" + }, + "last_fired_at": { + "title": "Last Fired At", + "type": "number" + }, + "fire_count": { + "title": "Fire Count", + "type": "integer" + } + }, + "required": [ + "prompt", + "status", + "interval_seconds", + "created_at", + "last_fired_at", + "fire_count" + ], + "title": "HeartbeatSnapshot", + "type": "object" + }, + "I18nCatalogParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "lang": { + "title": "Lang", + "type": "string" + }, + "surface": { + "$ref": "#/components/schemas/LocaleSurface", + "default": "core" + } + }, + "required": [ + "lang" + ], + "title": "I18nCatalogParams", + "type": "object" + }, + "I18nCatalogResult": { + "additionalProperties": false, + "description": "``messages`` is ONLY the pack + user-overlay layer for that surface (flat dotted keys); the\nclient merges it over its bundled ``en``/````. ``lang`` is the canonical id the request\nresolved to (``pt-BR`` \u2192 ``pt-br``; an unknown id resolves to ``en`` with an empty layer).", + "properties": { + "lang": { + "title": "Lang", + "type": "string" + }, + "surface": { + "$ref": "#/components/schemas/LocaleSurface" + }, + "messages": { + "additionalProperties": { + "type": "string" + }, + "title": "Messages", + "type": "object" + } + }, + "required": [ + "lang", + "surface", + "messages" + ], + "title": "I18nCatalogResult", + "type": "object" + }, + "I18nLanguagesResult": { + "additionalProperties": false, + "properties": { + "languages": { + "items": { + "$ref": "#/components/schemas/LanguageOption" + }, + "title": "Languages", + "type": "array" + } + }, + "required": [ + "languages" + ], + "title": "I18nLanguagesResult", + "type": "object" + }, + "ImageAttachBytesParams": { + "additionalProperties": false, + "description": "``content_base64`` (or the ``data`` alias) carries the bytes; ``filename`` / ``ext`` only hint\nthe extension \u2014 magic bytes decide.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "content_base64": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content Base64" + }, + "data": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data" + }, + "filename": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Filename" + }, + "ext": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ext" + } + }, + "required": [ + "session_id" + ], + "title": "ImageAttachBytesParams", + "type": "object" + }, + "ImageAttachParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "path": { + "title": "Path", + "type": "string" + } + }, + "required": [ + "session_id", + "path" + ], + "title": "ImageAttachParams", + "type": "object" + }, + "ImageDetachParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "path": { + "title": "Path", + "type": "string" + } + }, + "required": [ + "session_id", + "path" + ], + "title": "ImageDetachParams", + "type": "object" + }, + "ImageDetachResult": { + "additionalProperties": false, + "properties": { + "detached": { + "title": "Detached", + "type": "boolean" + }, + "count": { + "title": "Count", + "type": "integer" + } + }, + "required": [ + "detached", + "count" + ], + "title": "ImageDetachResult", + "type": "object" + }, + "ImageGenerateParams": { + "additionalProperties": false, + "properties": { + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompt" + }, + "aspect_ratio": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Aspect Ratio" + }, + "probe": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Probe" + }, + "max_bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Bytes" + } + }, + "title": "ImageGenerateParams", + "type": "object" + }, + "ImageGenerateResult": { + "additionalProperties": false, + "description": "``probe`` answers ``{available}`` alone; ``image_data`` (data URL) is omitted when the\ndownload failed or exceeded ``max_bytes`` so callers fall back to ``image``.", + "properties": { + "available": { + "title": "Available", + "type": "boolean" + }, + "success": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Success" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Image" + }, + "image_data": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Image Data" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + } + }, + "required": [ + "available" + ], + "title": "ImageGenerateResult", + "type": "object" + }, + "InflightTurn": { + "additionalProperties": false, + "description": "``session_auto_continue._inflight_snapshot``: the live (or retained failed) turn a reconnecting\nclient rebuilds its bubbles from.", + "properties": { + "assistant": { + "default": "", + "title": "Assistant", + "type": "string" + }, + "streaming": { + "default": false, + "title": "Streaming", + "type": "boolean" + }, + "user": { + "default": "", + "title": "User", + "type": "string" + }, + "display_kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Kind" + }, + "display_metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Metadata" + }, + "corrections": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Corrections" + }, + "correction_offsets": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Correction Offsets" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "recoverable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recoverable" + }, + "error_surface": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Surface" + } + }, + "title": "InflightTurn", + "type": "object" + }, + "InputDetectDropParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + } + }, + "required": [ + "session_id" + ], + "title": "InputDetectDropParams", + "type": "object" + }, + "InputDetectDropResult": { + "additionalProperties": false, + "description": "``matched: false`` alone when the text is not a drop; an image drop is queued immediately\n(``is_image`` + ``count``), a file drop only yields the ``text`` to insert.", + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "width": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Width" + }, + "height": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Height" + }, + "token_estimate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Token Estimate" + }, + "matched": { + "title": "Matched", + "type": "boolean" + }, + "is_image": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is Image" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + } + }, + "required": [ + "matched" + ], + "title": "InputDetectDropResult", + "type": "object" + }, + "InsightsGetParams": { + "additionalProperties": false, + "properties": { + "days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Days" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "InsightsGetParams", + "type": "object" + }, + "InsightsGetResult": { + "additionalProperties": false, + "properties": { + "days": { + "title": "Days", + "type": "integer" + }, + "sessions": { + "title": "Sessions", + "type": "integer" + }, + "messages": { + "title": "Messages", + "type": "integer" + } + }, + "required": [ + "days", + "sessions", + "messages" + ], + "title": "InsightsGetResult", + "type": "object" + }, + "InterruptStatus": { + "enum": [ + "interrupted", + "not_interrupted" + ], + "title": "InterruptStatus", + "type": "string" + }, + "LanguageOption": { + "additionalProperties": false, + "description": "``agent.i18n_languages.language_options`` row. ``source`` is ``bundled``, ``overlay`` or\n``plugin:`` \u2014 the highest layer that supplies the language.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "endonym": { + "title": "Endonym", + "type": "string" + }, + "rtl": { + "title": "Rtl", + "type": "boolean" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "id", + "endonym", + "rtl", + "source" + ], + "title": "LanguageOption", + "type": "object" + }, + "LayoutApplyPayload": { + "additionalProperties": true, + "description": "``tools/apply_layout_tool.py``.", + "properties": { + "preset": { + "title": "Preset", + "type": "string" + } + }, + "required": [ + "preset" + ], + "title": "LayoutApplyPayload", + "type": "object" + }, + "LearningAxis": { + "additionalProperties": false, + "properties": { + "start": { + "title": "Start", + "type": "string" + }, + "end": { + "title": "End", + "type": "string" + } + }, + "required": [ + "start", + "end" + ], + "title": "LearningAxis", + "type": "object" + }, + "LearningBucketRow": { + "additionalProperties": false, + "properties": { + "index": { + "title": "Index", + "type": "integer" + }, + "label": { + "title": "Label", + "type": "string" + }, + "date": { + "title": "Date", + "type": "string" + }, + "skills": { + "title": "Skills", + "type": "integer" + }, + "memories": { + "title": "Memories", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + }, + "category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Category" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Color" + }, + "nodes": { + "items": { + "$ref": "#/components/schemas/LearningNodeRow" + }, + "title": "Nodes", + "type": "array" + } + }, + "required": [ + "index", + "label", + "date", + "skills", + "memories", + "total", + "nodes" + ], + "title": "LearningBucketRow", + "type": "object" + }, + "LearningDetailResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Kind" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content" + } + }, + "required": [ + "ok" + ], + "title": "LearningDetailResult", + "type": "object" + }, + "LearningEditParams": { + "additionalProperties": false, + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content" + } + }, + "title": "LearningEditParams", + "type": "object" + }, + "LearningFrame": { + "additionalProperties": false, + "description": "``agent.learning_graph_render.render_graph`` projection; ``grid`` rows are lists of\n``[text, styleKey, alpha?, hexOverride?]`` runs.", + "properties": { + "reveal": { + "title": "Reveal", + "type": "number" + }, + "date": { + "title": "Date", + "type": "string" + }, + "visible": { + "title": "Visible", + "type": "integer" + }, + "grid": { + "items": { + "items": {}, + "type": "array" + }, + "title": "Grid", + "type": "array" + }, + "labels": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Labels", + "type": "array" + } + }, + "required": [ + "reveal", + "date", + "visible", + "grid" + ], + "title": "LearningFrame", + "type": "object" + }, + "LearningFramesParams": { + "additionalProperties": false, + "properties": { + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + }, + "rows": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rows" + }, + "frames": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Frames" + } + }, + "title": "LearningFramesParams", + "type": "object" + }, + "LearningFramesResult": { + "additionalProperties": false, + "properties": { + "frames": { + "items": { + "$ref": "#/components/schemas/LearningFrame" + }, + "title": "Frames", + "type": "array" + }, + "legend": { + "items": { + "$ref": "#/components/schemas/LearningLegendItem" + }, + "title": "Legend", + "type": "array" + }, + "categories": { + "items": { + "$ref": "#/components/schemas/LearningLegendItem" + }, + "title": "Categories", + "type": "array" + }, + "buckets": { + "items": { + "$ref": "#/components/schemas/LearningBucketRow" + }, + "title": "Buckets", + "type": "array" + }, + "summary": { + "items": { + "type": "string" + }, + "title": "Summary", + "type": "array" + }, + "axis": { + "$ref": "#/components/schemas/LearningAxis" + }, + "count": { + "title": "Count", + "type": "integer" + }, + "cols": { + "title": "Cols", + "type": "integer" + }, + "rows": { + "title": "Rows", + "type": "integer" + } + }, + "required": [ + "frames", + "legend", + "categories", + "buckets", + "summary", + "axis", + "count", + "cols", + "rows" + ], + "title": "LearningFramesResult", + "type": "object" + }, + "LearningLegendItem": { + "additionalProperties": false, + "properties": { + "glyph": { + "title": "Glyph", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "style": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Style" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Color" + } + }, + "required": [ + "glyph", + "label" + ], + "title": "LearningLegendItem", + "type": "object" + }, + "LearningMutationResult": { + "additionalProperties": false, + "description": "``agent.learning_mutations`` \u2014 ``ok: false`` carries the reason in ``message``.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + } + }, + "required": [ + "ok" + ], + "title": "LearningMutationResult", + "type": "object" + }, + "LearningNodeParams": { + "additionalProperties": false, + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + } + }, + "title": "LearningNodeParams", + "type": "object" + }, + "LearningNodeRow": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "glyph": { + "title": "Glyph", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "fullLabel": { + "title": "Fulllabel", + "type": "string" + }, + "meta": { + "title": "Meta", + "type": "string" + }, + "body": { + "title": "Body", + "type": "string" + }, + "style": { + "title": "Style", + "type": "string" + } + }, + "required": [ + "id", + "glyph", + "label", + "fullLabel", + "meta", + "body", + "style" + ], + "title": "LearningNodeRow", + "type": "object" + }, + "LeaseHolder": { + "enum": [ + "agent", + "human" + ], + "title": "LeaseHolder", + "type": "string" + }, + "LegacyPluginRow": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + } + }, + "required": [ + "name", + "version", + "enabled" + ], + "title": "LegacyPluginRow", + "type": "object" + }, + "LiveSessionStatus": { + "enum": [ + "idle", + "starting", + "waiting", + "working", + "streaming", + "resuming" + ], + "title": "LiveSessionStatus", + "type": "string" + }, + "LlmOneshotParams": { + "additionalProperties": false, + "description": "Needs a ``template`` or ``instructions`` / ``input``; a live ``session_id`` lends its model.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "template": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Template" + }, + "instructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Instructions" + }, + "input": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Input" + }, + "variables": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Variables" + }, + "task": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Task" + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Temperature" + }, + "max_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Tokens" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "title": "LlmOneshotParams", + "type": "object" + }, + "LlmOneshotResult": { + "additionalProperties": false, + "properties": { + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "LlmOneshotResult", + "type": "object" + }, + "LocaleSurface": { + "enum": [ + "core", + "tui", + "desktop" + ], + "title": "LocaleSurface", + "type": "string" + }, + "LoopSnapshot": { + "additionalProperties": false, + "description": "``_safe_loop_snapshot`` \u2014 persisted LoopState fields, never its route.", + "properties": { + "prompt": { + "title": "Prompt", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "mode": { + "title": "Mode", + "type": "string" + }, + "interval_seconds": { + "title": "Interval Seconds", + "type": "number" + }, + "current_delay": { + "title": "Current Delay", + "type": "number" + }, + "times": { + "title": "Times", + "type": "integer" + }, + "until": { + "title": "Until", + "type": "string" + }, + "max_ticks": { + "title": "Max Ticks", + "type": "integer" + }, + "ticks_fired": { + "title": "Ticks Fired", + "type": "integer" + }, + "created_at": { + "title": "Created At", + "type": "number" + }, + "last_fired_at": { + "title": "Last Fired At", + "type": "number" + }, + "next_due_at": { + "title": "Next Due At", + "type": "number" + }, + "awaiting_response": { + "title": "Awaiting Response", + "type": "boolean" + }, + "deferred_by_goal": { + "title": "Deferred By Goal", + "type": "boolean" + }, + "paused_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Paused Reason" + }, + "last_stop_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Stop Reason" + } + }, + "required": [ + "prompt", + "status", + "mode", + "interval_seconds", + "current_delay", + "times", + "until", + "max_ticks", + "ticks_fired", + "created_at", + "last_fired_at", + "next_due_at", + "awaiting_response", + "deferred_by_goal" + ], + "title": "LoopSnapshot", + "type": "object" + }, + "McpCatalogEntry": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "connector_slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Connector Slug" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "requires": { + "items": { + "type": "string" + }, + "title": "Requires", + "type": "array" + }, + "transport": { + "title": "Transport", + "type": "string" + } + }, + "required": [ + "name", + "description", + "installed", + "enabled", + "requires", + "transport" + ], + "title": "McpCatalogEntry", + "type": "object" + }, + "McpCatalogResult": { + "additionalProperties": false, + "properties": { + "servers": { + "items": { + "$ref": "#/components/schemas/McpCatalogEntry" + }, + "title": "Servers", + "type": "array" + } + }, + "required": [ + "servers" + ], + "title": "McpCatalogResult", + "type": "object" + }, + "McpOauthCallbackParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + }, + "session_id": { + "title": "Session Id", + "type": "string" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "State" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "iss": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Iss" + } + }, + "required": [ + "name", + "session_id" + ], + "title": "McpOauthCallbackParams", + "type": "object" + }, + "McpOauthCallbackResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Message" + } + }, + "required": [ + "ok" + ], + "title": "McpOauthCallbackResult", + "type": "object" + }, + "McpOauthCancelResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Message" + } + }, + "required": [ + "ok" + ], + "title": "McpOauthCancelResult", + "type": "object" + }, + "McpOauthFlowParams": { + "additionalProperties": false, + "description": "``session_id`` is the OAuth flow id returned by ``oauth.start`` (not a gateway session).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + }, + "session_id": { + "title": "Session Id", + "type": "string" + } + }, + "required": [ + "name", + "session_id" + ], + "title": "McpOauthFlowParams", + "type": "object" + }, + "McpOauthPollResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "status": { + "$ref": "#/components/schemas/McpOauthPollStatus" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Message" + }, + "auth_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Auth Url" + }, + "tools": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/McpProbeTool" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tools" + } + }, + "required": [ + "ok", + "status" + ], + "title": "McpOauthPollResult", + "type": "object" + }, + "McpOauthPollStatus": { + "enum": [ + "pending", + "approved", + "error" + ], + "title": "McpOauthPollStatus", + "type": "string" + }, + "McpOauthStartParams": { + "additionalProperties": false, + "description": "With ``client_redirect_uri`` the CLIENT hosts the loopback and relays the code via\n``mcp.servers.oauth.callback``.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + }, + "client_redirect_uri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Client Redirect Uri" + } + }, + "required": [ + "name" + ], + "title": "McpOauthStartParams", + "type": "object" + }, + "McpOauthStartResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "session_id": { + "title": "Session Id", + "type": "string" + }, + "auth_url": { + "title": "Auth Url", + "type": "string" + }, + "flow": { + "title": "Flow", + "type": "string" + } + }, + "required": [ + "ok", + "session_id", + "auth_url", + "flow" + ], + "title": "McpOauthStartResult", + "type": "object" + }, + "McpProbeTool": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + } + }, + "required": [ + "name", + "description" + ], + "title": "McpProbeTool", + "type": "object" + }, + "McpRuntimeStatus": { + "enum": [ + "connected", + "disabled", + "connecting", + "failed", + "lazy", + "configured" + ], + "title": "McpRuntimeStatus", + "type": "string" + }, + "McpServerEntry": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "transport": { + "default": "stdio", + "title": "Transport", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "McpServerEntry", + "type": "object" + }, + "McpServerNameParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "McpServerNameParams", + "type": "object" + }, + "McpServerRuntimeRow": { + "additionalProperties": false, + "description": "Safe projection of ``tools.mcp_tool_discovery.get_mcp_status`` rows.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "transport": { + "title": "Transport", + "type": "string" + }, + "tools": { + "title": "Tools", + "type": "integer" + }, + "connected": { + "title": "Connected", + "type": "boolean" + }, + "disabled": { + "title": "Disabled", + "type": "boolean" + }, + "status": { + "$ref": "#/components/schemas/McpRuntimeStatus" + }, + "source": { + "$ref": "#/components/schemas/McpServerSource" + }, + "plugin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Plugin" + } + }, + "required": [ + "name", + "transport", + "tools", + "connected", + "disabled", + "status", + "source" + ], + "title": "McpServerRuntimeRow", + "type": "object" + }, + "McpServerSource": { + "enum": [ + "config", + "plugin" + ], + "title": "McpServerSource", + "type": "string" + }, + "McpServerStatus": { + "additionalProperties": true, + "properties": { + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "tool_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Count" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + } + }, + "title": "McpServerStatus", + "type": "object" + }, + "McpServerSummary": { + "additionalProperties": false, + "description": "``tui_gateway/mcp_rpc_helpers.summarize_server`` \u2014 a server's config without secret values.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "transport": { + "title": "Transport", + "type": "string" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "args": { + "items": { + "type": "string" + }, + "title": "Args", + "type": "array" + }, + "env": { + "items": { + "type": "string" + }, + "title": "Env", + "type": "array" + }, + "auth": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Auth" + }, + "oauth_tokens_present": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Oauth Tokens Present" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "tools": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Tools" + }, + "source": { + "$ref": "#/components/schemas/McpServerSource" + }, + "plugin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Plugin" + } + }, + "required": [ + "name", + "transport", + "args", + "env", + "enabled", + "source" + ], + "title": "McpServerSummary", + "type": "object" + }, + "McpServersAddParams": { + "additionalProperties": false, + "description": "``preset`` (catalog id) and/or ``config`` (url/command/args/env/headers/auth/tools); a\n``bearer_token`` is written to the profile's .env, only the header template persists.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + }, + "preset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Preset" + }, + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Config" + }, + "bearer_token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Bearer Token" + } + }, + "required": [ + "name" + ], + "title": "McpServersAddParams", + "type": "object" + }, + "McpServersAddResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "server": { + "$ref": "#/components/schemas/McpServerSummary" + } + }, + "required": [ + "ok", + "name", + "server" + ], + "title": "McpServersAddResult", + "type": "object" + }, + "McpServersListResult": { + "additionalProperties": false, + "properties": { + "servers": { + "items": { + "$ref": "#/components/schemas/McpServerSummary" + }, + "title": "Servers", + "type": "array" + } + }, + "required": [ + "servers" + ], + "title": "McpServersListResult", + "type": "object" + }, + "McpServersRemoveResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "removed": { + "title": "Removed", + "type": "boolean" + } + }, + "required": [ + "ok", + "removed" + ], + "title": "McpServersRemoveResult", + "type": "object" + }, + "McpServersSetApiKeyParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + }, + "env_var": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Env Var" + } + }, + "required": [ + "name", + "value" + ], + "title": "McpServersSetApiKeyParams", + "type": "object" + }, + "McpServersSetApiKeyResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "env_var": { + "title": "Env Var", + "type": "string" + }, + "server": { + "$ref": "#/components/schemas/McpServerSummary" + } + }, + "required": [ + "ok", + "name", + "env_var", + "server" + ], + "title": "McpServersSetApiKeyResult", + "type": "object" + }, + "McpServersStatusResult": { + "additionalProperties": false, + "properties": { + "servers": { + "items": { + "$ref": "#/components/schemas/McpServerRuntimeRow" + }, + "title": "Servers", + "type": "array" + }, + "checked_at": { + "title": "Checked At", + "type": "integer" + } + }, + "required": [ + "servers", + "checked_at" + ], + "title": "McpServersStatusResult", + "type": "object" + }, + "McpServersTestResult": { + "additionalProperties": false, + "description": "``ok: false`` carries ``error``; ``prompts`` / ``resources`` are only counted on success.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "tools": { + "items": { + "$ref": "#/components/schemas/McpProbeTool" + }, + "title": "Tools", + "type": "array" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "prompts": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompts" + }, + "resources": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resources" + }, + "oauth_needed": { + "title": "Oauth Needed", + "type": "boolean" + }, + "oauth_tokens_present": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Oauth Tokens Present" + } + }, + "required": [ + "ok", + "tools", + "oauth_needed" + ], + "title": "McpServersTestResult", + "type": "object" + }, + "MessageCompletePayload": { + "additionalProperties": false, + "description": "``prompt_turn._complete_turn_payload`` / ``session_auto_continue._emit_terminal_turn_error`` /\n``agent_callbacks._mirror_subagent_to_child`` (child watch mirror: ``text`` only) /\n``compute_host_bridge`` (``text`` + ``status``).", + "properties": { + "text": { + "anyOf": [ + { + "type": "string" + }, + {} + ], + "default": "", + "title": "Text" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/components/schemas/Usage" + }, + { + "type": "null" + } + ], + "default": null + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/TurnStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "reasoning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reasoning" + }, + "warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Warning" + }, + "response_previewed": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Previewed" + }, + "response_transformed": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Transformed" + }, + "billing": { + "anyOf": [ + { + "$ref": "#/components/schemas/BillingBlock" + }, + { + "type": "null" + } + ], + "default": null + }, + "failure_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Failure Reason" + }, + "rendered": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rendered" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "recoverable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recoverable" + }, + "error_surface": { + "anyOf": [ + { + "$ref": "#/components/schemas/ErrorSurface" + }, + { + "type": "null" + } + ], + "default": null + }, + "partial": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Partial" + }, + "persisted_turn": { + "anyOf": [ + { + "$ref": "#/components/schemas/PersistedTurn" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "MessageCompletePayload", + "type": "object" + }, + "MessageInterimPayload": { + "additionalProperties": false, + "description": "``prompt_turn._interim_assistant_cb`` / ``agent_callbacks`` interim_assistant_callback.", + "properties": { + "text": { + "title": "Text", + "type": "string" + }, + "already_streamed": { + "title": "Already Streamed", + "type": "boolean" + } + }, + "required": [ + "text", + "already_streamed" + ], + "title": "MessageInterimPayload", + "type": "object" + }, + "MessageReactParams": { + "additionalProperties": false, + "description": "``row_id`` is ``messages.id``; a not-yet-persisted live message names ``newest_role`` instead.\n``emoji`` null clears; the same emoji again retracts.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "row_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Row Id" + }, + "newest_role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Newest Role" + }, + "emoji": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Emoji" + }, + "author": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReactionAuthor" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "session_id" + ], + "title": "MessageReactParams", + "type": "object" + }, + "MessageReactResult": { + "additionalProperties": false, + "properties": { + "row_id": { + "title": "Row Id", + "type": "integer" + }, + "reactions": { + "items": { + "$ref": "#/components/schemas/MessageReaction" + }, + "title": "Reactions", + "type": "array" + } + }, + "required": [ + "row_id", + "reactions" + ], + "title": "MessageReactResult", + "type": "object" + }, + "MessageReaction": { + "additionalProperties": true, + "description": "One persisted reaction row (``hermes_state_messages.set_message_reaction``); ``seen`` is\nstamped once announced.", + "properties": { + "emoji": { + "title": "Emoji", + "type": "string" + }, + "author": { + "title": "Author", + "type": "string" + }, + "at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "At" + }, + "seen": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Seen" + } + }, + "required": [ + "emoji", + "author" + ], + "title": "MessageReaction", + "type": "object" + }, + "MessageReactionPayload": { + "additionalProperties": false, + "description": "``tools/react_to_message_tool.py``.", + "properties": { + "row_id": { + "title": "Row Id", + "type": "integer" + }, + "reactions": { + "items": { + "$ref": "#/components/schemas/MessageReaction" + }, + "title": "Reactions", + "type": "array" + }, + "role": { + "title": "Role", + "type": "string" + } + }, + "required": [ + "row_id", + "reactions", + "role" + ], + "title": "MessageReactionPayload", + "type": "object" + }, + "MoaAggregatingPayload": { + "additionalProperties": false, + "properties": { + "aggregator": { + "title": "Aggregator", + "type": "string" + } + }, + "required": [ + "aggregator" + ], + "title": "MoaAggregatingPayload", + "type": "object" + }, + "MoaPhasePayload": { + "additionalProperties": false, + "properties": { + "phase": { + "title": "Phase", + "type": "string" + }, + "refs_done": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Refs Done" + }, + "refs_total": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Refs Total" + }, + "aggregator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Aggregator" + } + }, + "required": [ + "phase" + ], + "title": "MoaPhasePayload", + "type": "object" + }, + "MoaProgressPayload": { + "additionalProperties": false, + "properties": { + "label": { + "title": "Label", + "type": "string" + }, + "refs_done": { + "title": "Refs Done", + "type": "integer" + }, + "refs_total": { + "title": "Refs Total", + "type": "integer" + } + }, + "required": [ + "label", + "refs_done", + "refs_total" + ], + "title": "MoaProgressPayload", + "type": "object" + }, + "MoaReferencePayload": { + "additionalProperties": false, + "description": "``tool_progress._progress_moa_reference``.", + "properties": { + "label": { + "title": "Label", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Index" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + } + }, + "required": [ + "label", + "text" + ], + "title": "MoaReferencePayload", + "type": "object" + }, + "ModelCapabilities": { + "additionalProperties": false, + "description": "``hermes_cli/inventory.py::_apply_capabilities``.", + "properties": { + "fast": { + "title": "Fast", + "type": "boolean" + }, + "reasoning": { + "title": "Reasoning", + "type": "boolean" + }, + "can_disable_reasoning": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Can Disable Reasoning" + } + }, + "required": [ + "fast", + "reasoning" + ], + "title": "ModelCapabilities", + "type": "object" + }, + "ModelDisconnectParams": { + "additionalProperties": false, + "properties": { + "slug": { + "title": "Slug", + "type": "string" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "required": [ + "slug" + ], + "title": "ModelDisconnectParams", + "type": "object" + }, + "ModelDisconnectResult": { + "additionalProperties": false, + "properties": { + "slug": { + "title": "Slug", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "disconnected": { + "title": "Disconnected", + "type": "boolean" + } + }, + "required": [ + "slug", + "name", + "disconnected" + ], + "title": "ModelDisconnectResult", + "type": "object" + }, + "ModelOptionProvider": { + "additionalProperties": true, + "description": "One ``hermes_cli/inventory.py::build_models_payload`` provider row (the union of every field\nthe builder sets; ``pricing_pending`` / ``free_tier_pending`` mark the cached-only path).", + "properties": { + "slug": { + "title": "Slug", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "models": { + "items": { + "type": "string" + }, + "title": "Models", + "type": "array" + }, + "total_models": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Total Models" + }, + "is_current": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is Current" + }, + "is_user_defined": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is User Defined" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + }, + "aliases": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Aliases" + }, + "api_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Url" + }, + "auth_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Auth Type" + }, + "authenticated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authenticated" + }, + "key_env": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key Env" + }, + "warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Warning" + }, + "featured_models": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Featured Models" + }, + "capabilities": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/ModelCapabilities" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Capabilities" + }, + "pricing": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/ModelPricing" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pricing" + }, + "pricing_pending": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pricing Pending" + }, + "free_tier": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Tier" + }, + "free_tier_pending": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Tier Pending" + }, + "free_tier_row": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Tier Row" + }, + "unavailable_models": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unavailable Models" + } + }, + "required": [ + "slug", + "name" + ], + "title": "ModelOptionProvider", + "type": "object" + }, + "ModelOptionsParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "explicit_only": { + "default": false, + "title": "Explicit Only", + "type": "boolean" + }, + "include_unconfigured": { + "default": false, + "title": "Include Unconfigured", + "type": "boolean" + }, + "refresh": { + "default": false, + "title": "Refresh", + "type": "boolean" + } + }, + "title": "ModelOptionsParams", + "type": "object" + }, + "ModelOptionsResult": { + "additionalProperties": false, + "properties": { + "providers": { + "items": { + "$ref": "#/components/schemas/ModelOptionProvider" + }, + "title": "Providers", + "type": "array" + }, + "model": { + "default": "", + "title": "Model", + "type": "string" + }, + "provider": { + "default": "", + "title": "Provider", + "type": "string" + } + }, + "required": [ + "providers" + ], + "title": "ModelOptionsResult", + "type": "object" + }, + "ModelPricing": { + "additionalProperties": false, + "description": "``hermes_cli/inventory.py::_apply_pricing`` \u2014 formatted $/Mtok strings (``\"\"`` unknown,\n``\"free\"``); the sale fields are Nous Portal-only.", + "properties": { + "input": { + "title": "Input", + "type": "string" + }, + "output": { + "title": "Output", + "type": "string" + }, + "cache": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cache" + }, + "free": { + "title": "Free", + "type": "boolean" + }, + "discount_percent": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Discount Percent" + }, + "was_input": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Was Input" + }, + "was_output": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Was Output" + } + }, + "required": [ + "input", + "output", + "free" + ], + "title": "ModelPricing", + "type": "object" + }, + "ModelSaveKeyParams": { + "additionalProperties": false, + "properties": { + "slug": { + "title": "Slug", + "type": "string" + }, + "api_key": { + "title": "Api Key", + "type": "string" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "required": [ + "slug", + "api_key" + ], + "title": "ModelSaveKeyParams", + "type": "object" + }, + "ModelSaveKeyResult": { + "additionalProperties": false, + "properties": { + "provider": { + "$ref": "#/components/schemas/ModelOptionProvider" + } + }, + "required": [ + "provider" + ], + "title": "ModelSaveKeyResult", + "type": "object" + }, + "NoticePayload": { + "additionalProperties": false, + "description": "``tui_gateway/model_switch.py`` capability-refresh notice.", + "properties": { + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "message" + ], + "title": "NoticePayload", + "type": "object" + }, + "NotificationClearPayload": { + "additionalProperties": false, + "properties": { + "key": { + "title": "Key", + "type": "string" + } + }, + "required": [ + "key" + ], + "title": "NotificationClearPayload", + "type": "object" + }, + "NotificationShowPayload": { + "additionalProperties": false, + "description": "``agent/credits_tracker.py::AgentNotice`` via notice_callback, and ``server._await_agent_ready``'s\nslow-build notice. ``level``: info | warn | error | success; ``kind``: sticky | ttl | agent.", + "properties": { + "text": { + "title": "Text", + "type": "string" + }, + "level": { + "title": "Level", + "type": "string" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "ttl_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ttl Ms" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + } + }, + "required": [ + "text", + "level", + "kind" + ], + "title": "NotificationShowPayload", + "type": "object" + }, + "OkResult": { + "additionalProperties": false, + "properties": { + "ok": { + "default": true, + "title": "Ok", + "type": "boolean" + } + }, + "title": "OkResult", + "type": "object" + }, + "OnboardingAnswers": { + "additionalProperties": true, + "description": "``tui_gateway/onboarding_personalization.py`` \u2014 the facts agreed during onboarding.", + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "context": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context" + }, + "theme": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Theme" + }, + "accent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Accent" + }, + "layout": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Layout" + }, + "focus": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Focus" + }, + "connectors": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Connectors" + }, + "plugins": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Plugins" + } + }, + "title": "OnboardingAnswers", + "type": "object" + }, + "OnboardingCatalogPlugin": { + "additionalProperties": false, + "description": "A catalog plugin curated for the onboarding card (``onboarding: true``) that this OS runs.\n``app_state`` is the pinned ``plugin.json`` declaration judged on this host; ``sentence`` names what\nis missing (empty when present or unknown).", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "tier": { + "$ref": "#/components/schemas/CatalogTier" + }, + "platforms": { + "items": { + "type": "string" + }, + "title": "Platforms", + "type": "array" + }, + "app_state": { + "$ref": "#/components/schemas/CatalogAppState" + }, + "sentence": { + "title": "Sentence", + "type": "string" + } + }, + "required": [ + "name", + "title", + "description", + "tier", + "platforms", + "app_state", + "sentence" + ], + "title": "OnboardingCatalogPlugin", + "type": "object" + }, + "OnboardingEnsureSetupProfileResult": { + "additionalProperties": false, + "description": "``created`` is false when an existing setup profile was found (and returned untouched).", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "created": { + "title": "Created", + "type": "boolean" + }, + "role": { + "const": "setup", + "default": "setup", + "title": "Role", + "type": "string" + } + }, + "required": [ + "name", + "path", + "created" + ], + "title": "OnboardingEnsureSetupProfileResult", + "type": "object" + }, + "OnboardingResetSetupProfileResult": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "reset": { + "default": true, + "title": "Reset", + "type": "boolean" + } + }, + "required": [ + "name", + "path" + ], + "title": "OnboardingResetSetupProfileResult", + "type": "object" + }, + "OpenRequestEntry": { + "additionalProperties": false, + "description": "One unanswered server\u2192client request (``server_requests.Request.snapshot``); the reconnecting\nclient re-delivers it to its request handlers.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "method": { + "title": "Method", + "type": "string" + }, + "params": { + "additionalProperties": true, + "title": "Params", + "type": "object" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "OpenRequestEntry", + "type": "object" + }, + "OptionalProjectResult": { + "additionalProperties": false, + "properties": { + "project": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectInfo" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "OptionalProjectResult", + "type": "object" + }, + "PaneRevealPayload": { + "additionalProperties": true, + "description": "``tools/focus_pane_tool.py``.", + "properties": { + "pane": { + "title": "Pane", + "type": "string" + } + }, + "required": [ + "pane" + ], + "title": "PaneRevealPayload", + "type": "object" + }, + "Params": { + "additionalProperties": false, + "description": "Client\u2192server method params / server\u2192client request params. Unknown keys are rejected.", + "properties": {}, + "title": "Params", + "type": "object" + }, + "PasteCollapseParams": { + "additionalProperties": false, + "properties": { + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + } + }, + "title": "PasteCollapseParams", + "type": "object" + }, + "PasteCollapseResult": { + "additionalProperties": false, + "properties": { + "placeholder": { + "title": "Placeholder", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "lines": { + "title": "Lines", + "type": "integer" + } + }, + "required": [ + "placeholder", + "path", + "lines" + ], + "title": "PasteCollapseResult", + "type": "object" + }, + "PaymentMethodKind": { + "enum": [ + "card", + "link", + "unknown" + ], + "title": "PaymentMethodKind", + "type": "string" + }, + "PdfAttachParams": { + "additionalProperties": false, + "description": "Host ``path`` or base64 ``content_base64`` / ``data``; ``first_page`` / ``last_page`` bound the\nrender (per-call page cap enforced server-side).", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "content_base64": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content Base64" + }, + "data": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data" + }, + "filename": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Filename" + }, + "first_page": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "First Page" + }, + "last_page": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Page" + } + }, + "required": [ + "session_id" + ], + "title": "PdfAttachParams", + "type": "object" + }, + "PdfAttachResult": { + "additionalProperties": false, + "properties": { + "attached": { + "title": "Attached", + "type": "boolean" + }, + "filename": { + "title": "Filename", + "type": "string" + }, + "pages_attached": { + "title": "Pages Attached", + "type": "integer" + }, + "pages": { + "items": { + "$ref": "#/components/schemas/PdfPage" + }, + "title": "Pages", + "type": "array" + }, + "count": { + "title": "Count", + "type": "integer" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "attached", + "filename", + "pages_attached", + "pages", + "count", + "text" + ], + "title": "PdfAttachResult", + "type": "object" + }, + "PdfPage": { + "additionalProperties": false, + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "width": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Width" + }, + "height": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Height" + }, + "token_estimate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Token Estimate" + }, + "path": { + "title": "Path", + "type": "string" + }, + "page": { + "title": "Page", + "type": "integer" + } + }, + "required": [ + "path", + "page" + ], + "title": "PdfPage", + "type": "object" + }, + "PeerRouteStatus": { + "additionalProperties": false, + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "member_id": { + "title": "Member Id", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "room_id", + "member_id", + "status" + ], + "title": "PeerRouteStatus", + "type": "object" + }, + "PendingApproval": { + "additionalProperties": true, + "description": "One unresolved ``tools/approval.py`` gateway queue entry as ``server._approval_request_payload``\nrenders it (command redacted; ``choices`` precomputed). The key set is owned by the approval tool.", + "properties": { + "request_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Request Id" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "pattern_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pattern Key" + }, + "pattern_keys": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pattern Keys" + }, + "allow_permanent": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Allow Permanent" + }, + "allow_session": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Allow Session" + }, + "smart_denied": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Smart Denied" + }, + "choices": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Choices" + }, + "tool_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Name" + } + }, + "title": "PendingApproval", + "type": "object" + }, + "PersistedTurn": { + "additionalProperties": false, + "description": "Committed SQLite row addresses for the agent's current-turn suffix. Missing ids are\nunproven, never negative acknowledgements. ``complete`` permits retiring the whole local\nturn only when the original turn boundary, every row and final body are still accounted\nfor; compaction, redirects and partial writes conservatively leave it false. Row ids are\nscoped to the owning profile's store, as in ``SessionMessage.row_id``.", + "properties": { + "row_ids": { + "items": { + "type": "integer" + }, + "title": "Row Ids", + "type": "array" + }, + "complete": { + "title": "Complete", + "type": "boolean" + }, + "user_row_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "User Row Id" + }, + "final_assistant_row_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Final Assistant Row Id" + } + }, + "required": [ + "row_ids", + "complete" + ], + "title": "PersistedTurn", + "type": "object" + }, + "PetCancelParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Token" + } + }, + "title": "PetCancelParams", + "type": "object" + }, + "PetCancelResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "PetCancelResult", + "type": "object" + }, + "PetCellsParams": { + "additionalProperties": false, + "description": "``graphics`` opts into the kitty payload when the TTY speaks it; ``cols`` overrides the width.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "State" + }, + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + }, + "graphics": { + "default": false, + "title": "Graphics", + "type": "boolean" + } + }, + "title": "PetCellsParams", + "type": "object" + }, + "PetCellsResult": { + "additionalProperties": false, + "description": "Unicode: ``frames`` is frame \u2192 row \u2192 cell ``[tr,tg,tb,ta, br,bg,bb,ba]``; kitty (``graphics``\nset): ``frames`` are transmit escapes and ``placeholder`` the text grid.", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Slug" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Displayname" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "State" + }, + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + }, + "frameMs": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framems" + }, + "frames": { + "anyOf": [ + { + "items": { + "items": { + "items": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "type": "array" + }, + "type": "array" + }, + "type": "array" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Frames" + }, + "scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scale" + }, + "graphics": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Graphics" + }, + "imageId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Imageid" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Color" + }, + "rows": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rows" + }, + "placeholder": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Placeholder" + } + }, + "required": [ + "enabled" + ], + "title": "PetCellsResult", + "type": "object" + }, + "PetChangedPayload": { + "additionalProperties": true, + "description": "``change_watcher._pet_changed_payload`` \u2014 ``pet.info.meta``-shaped; ``{enabled: false}`` when off.", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Slug" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Displayname" + }, + "scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scale" + }, + "spritesheetRevision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spritesheetrevision" + } + }, + "required": [ + "enabled" + ], + "title": "PetChangedPayload", + "type": "object" + }, + "PetDraft": { + "additionalProperties": false, + "properties": { + "index": { + "title": "Index", + "type": "integer" + }, + "dataUri": { + "title": "Datauri", + "type": "string" + } + }, + "required": [ + "index", + "dataUri" + ], + "title": "PetDraft", + "type": "object" + }, + "PetExportResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "filename": { + "title": "Filename", + "type": "string" + }, + "zipBase64": { + "title": "Zipbase64", + "type": "string" + } + }, + "required": [ + "ok", + "filename", + "zipBase64" + ], + "title": "PetExportResult", + "type": "object" + }, + "PetGalleryEntry": { + "additionalProperties": false, + "properties": { + "slug": { + "title": "Slug", + "type": "string" + }, + "displayName": { + "title": "Displayname", + "type": "string" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "spritesheetUrl": { + "default": "", + "title": "Spritesheeturl", + "type": "string" + }, + "curated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Curated" + }, + "generated": { + "default": false, + "title": "Generated", + "type": "boolean" + } + }, + "required": [ + "slug", + "displayName", + "installed" + ], + "title": "PetGalleryEntry", + "type": "object" + }, + "PetGalleryParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "localOnly": { + "default": false, + "title": "Localonly", + "type": "boolean" + } + }, + "title": "PetGalleryParams", + "type": "object" + }, + "PetGalleryResult": { + "additionalProperties": false, + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "active": { + "default": "", + "title": "Active", + "type": "string" + }, + "pets": { + "items": { + "$ref": "#/components/schemas/PetGalleryEntry" + }, + "title": "Pets", + "type": "array" + } + }, + "required": [ + "enabled" + ], + "title": "PetGalleryResult", + "type": "object" + }, + "PetGenProvider": { + "additionalProperties": false, + "description": "``agent/pet/generate/imagegen.py::list_sprite_providers`` row.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "default": { + "type": "boolean", + "title": "Default" + } + }, + "required": [ + "name", + "label", + "default" + ], + "title": "PetGenProvider", + "type": "object" + }, + "PetGenerateParams": { + "additionalProperties": false, + "description": "``prompt`` or a ``referenceImage`` data URL is required (the handler answers 4004 without one).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompt" + }, + "referenceImage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Referenceimage" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + }, + "style": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Style" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + } + }, + "title": "PetGenerateParams", + "type": "object" + }, + "PetGenerateProgressPayload": { + "additionalProperties": true, + "description": "``methods_session`` pet.generate: token-only init frame, then one per draft.", + "properties": { + "token": { + "title": "Token", + "type": "string" + }, + "count": { + "title": "Count", + "type": "integer" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Index" + }, + "dataUri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Datauri" + } + }, + "required": [ + "token", + "count" + ], + "title": "PetGenerateProgressPayload", + "type": "object" + }, + "PetGenerateResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "token": { + "title": "Token", + "type": "string" + }, + "drafts": { + "items": { + "$ref": "#/components/schemas/PetDraft" + }, + "title": "Drafts", + "type": "array" + } + }, + "required": [ + "ok", + "token", + "drafts" + ], + "title": "PetGenerateResult", + "type": "object" + }, + "PetGenerateStatusResult": { + "additionalProperties": false, + "properties": { + "available": { + "title": "Available", + "type": "boolean" + }, + "providers": { + "items": { + "$ref": "#/components/schemas/PetGenProvider" + }, + "title": "Providers", + "type": "array" + } + }, + "required": [ + "available", + "providers" + ], + "title": "PetGenerateStatusResult", + "type": "object" + }, + "PetHatchParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "token": { + "title": "Token", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "cancelToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Canceltoken" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Index" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompt" + }, + "style": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Style" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + } + }, + "required": [ + "token", + "name" + ], + "title": "PetHatchParams", + "type": "object" + }, + "PetHatchProgressPayload": { + "additionalProperties": true, + "description": "``methods_session`` pet.hatch ``_on_progress``: ``{event, detail}`` or the parsed row form.", + "properties": { + "event": { + "title": "Event", + "type": "string" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Detail" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "State" + }, + "done": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Done" + }, + "total": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Total" + } + }, + "required": [ + "event" + ], + "title": "PetHatchProgressPayload", + "type": "object" + }, + "PetHatchResult": { + "additionalProperties": false, + "description": "The hatched pet is installed but NOT active (``pet.select`` adopts, ``pet.remove`` discards).", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "displayName": { + "title": "Displayname", + "type": "string" + }, + "warnings": { + "items": {}, + "title": "Warnings", + "type": "array" + }, + "pet": { + "$ref": "#/components/schemas/PetSpritePayload" + } + }, + "required": [ + "ok", + "slug", + "displayName", + "pet" + ], + "title": "PetHatchResult", + "type": "object" + }, + "PetInfoMetaResult": { + "additionalProperties": false, + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Slug" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Displayname" + }, + "scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scale" + }, + "spritesheetRevision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spritesheetrevision" + } + }, + "required": [ + "enabled" + ], + "title": "PetInfoMetaResult", + "type": "object" + }, + "PetInfoParams": { + "additionalProperties": false, + "description": "``knownRevision``: the spritesheet revision the caller already holds (send-once bytes).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "knownRevision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Knownrevision" + } + }, + "title": "PetInfoParams", + "type": "object" + }, + "PetInfoResult": { + "additionalProperties": true, + "description": "``server._pet_sprite_payload`` behind ``enabled``; every sprite field is absent when the pet\ndisplay is off, ``spritesheetBase64`` is elided when ``spritesheetUnchanged``.", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Slug" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Displayname" + }, + "mime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mime" + }, + "spritesheetBase64": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spritesheetbase64" + }, + "spritesheetRevision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spritesheetrevision" + }, + "spritesheetUnchanged": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spritesheetunchanged" + }, + "frameW": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framew" + }, + "frameH": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Frameh" + }, + "framesPerState": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framesperstate" + }, + "framesByState": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framesbystate" + }, + "framesByRow": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framesbyrow" + }, + "loopMs": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Loopms" + }, + "scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scale" + }, + "stateRows": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Staterows" + } + }, + "required": [ + "enabled" + ], + "title": "PetInfoResult", + "type": "object" + }, + "PetRenameParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "slug", + "name" + ], + "title": "PetRenameParams", + "type": "object" + }, + "PetScaleParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "scale": { + "default": null, + "title": "Scale" + } + }, + "title": "PetScaleParams", + "type": "object" + }, + "PetScaleResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "scale": { + "title": "Scale", + "type": "number" + } + }, + "required": [ + "ok", + "scale" + ], + "title": "PetScaleResult", + "type": "object" + }, + "PetSlugParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "slug": { + "title": "Slug", + "type": "string" + } + }, + "required": [ + "slug" + ], + "title": "PetSlugParams", + "type": "object" + }, + "PetSlugResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Displayname" + } + }, + "required": [ + "ok", + "slug" + ], + "title": "PetSlugResult", + "type": "object" + }, + "PetSpritePayload": { + "additionalProperties": false, + "properties": { + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Slug" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Displayname" + }, + "mime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mime" + }, + "spritesheetBase64": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spritesheetbase64" + }, + "spritesheetRevision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spritesheetrevision" + }, + "frameW": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framew" + }, + "frameH": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Frameh" + }, + "framesPerState": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framesperstate" + }, + "framesByState": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framesbystate" + }, + "framesByRow": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framesbyrow" + }, + "loopMs": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Loopms" + }, + "scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scale" + }, + "stateRows": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Staterows" + } + }, + "title": "PetSpritePayload", + "type": "object" + }, + "PetThumbParams": { + "additionalProperties": false, + "description": "``url``: spritesheet source for a not-yet-installed pet.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + } + }, + "required": [ + "slug" + ], + "title": "PetThumbParams", + "type": "object" + }, + "PetThumbResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "dataUri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Datauri" + } + }, + "required": [ + "ok", + "slug" + ], + "title": "PetThumbResult", + "type": "object" + }, + "PingParams": { + "additionalProperties": false, + "properties": {}, + "title": "PingParams", + "type": "object" + }, + "PingResult": { + "additionalProperties": false, + "properties": { + "pong": { + "title": "Pong", + "type": "boolean" + } + }, + "required": [ + "pong" + ], + "title": "PingResult", + "type": "object" + }, + "PluginActivation": { + "additionalProperties": false, + "description": "What a plugin loaded mid-run does NOW vs later (``hermes_cli.plugins_activation``). ``activated_now``\nkinds (``{kind: [names]}``): ``gateway_commands`` (slash names), ``locales`` (``.``\nlanguage-pack layers), ``gateway_transforms`` / ``hooks`` (hook\nnames), ``callbacks`` (platforms / ``slack:``) \u2014 live in the running gateway once it reloaded\n(``gateway_reloaded``). ``live_now``: the plugin's MCP servers (connected, with their tools, or the\nerror) and skills, usable in every open chat of the profile from its next turn \u2014 the chats also get a\nnote listing them. ``deferred`` kinds: ``tools`` (Python tool names) and ``prompt`` (section ids)\napply from the next session.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "key": { + "title": "Key", + "type": "string" + }, + "activated_now": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Activated Now", + "type": "object" + }, + "live_now": { + "anyOf": [ + { + "$ref": "#/components/schemas/PluginLiveNow" + }, + { + "type": "null" + } + ], + "default": null + }, + "deferred": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Deferred", + "type": "object" + } + }, + "required": [ + "name", + "key" + ], + "title": "PluginActivation", + "type": "object" + }, + "PluginLiveNow": { + "additionalProperties": false, + "properties": { + "mcp_servers": { + "items": { + "$ref": "#/components/schemas/PluginLiveServer" + }, + "title": "Mcp Servers", + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/components/schemas/PluginLiveSkill" + }, + "title": "Skills", + "type": "array" + } + }, + "title": "PluginLiveNow", + "type": "object" + }, + "PluginLiveServer": { + "additionalProperties": false, + "description": "One plugin MCP server connected at activation: its callable tool names, or the reason it did not connect.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "connected": { + "title": "Connected", + "type": "boolean" + }, + "tools": { + "items": { + "type": "string" + }, + "title": "Tools", + "type": "array" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + } + }, + "required": [ + "name", + "connected" + ], + "title": "PluginLiveServer", + "type": "object" + }, + "PluginLiveSkill": { + "additionalProperties": false, + "description": "One plugin skill usable now through ``skill_view`` (qualified ``:``).", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "PluginLiveSkill", + "type": "object" + }, + "PluginServerRow": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/PluginServerState" + }, + "sentence": { + "title": "Sentence", + "type": "string" + } + }, + "required": [ + "name", + "state", + "sentence" + ], + "title": "PluginServerRow", + "type": "object" + }, + "PluginServerState": { + "enum": [ + "connected", + "app_not_running", + "hermes_not_connected", + "endpoint_unavailable", + "no_interactive_session", + "version_too_old", + "missing_app", + "unknown" + ], + "title": "PluginServerState", + "type": "string" + }, + "PluginSettingField": { + "additionalProperties": false, + "description": "One ``config_schema`` key of a plugin manifest, rendered by the Plugins hub\n(``hermes_cli.plugins_settings.plugin_settings_fields``). ``secret`` fields carry no value: ``env``\nnames the ``.env`` variable and ``has_value`` whether it is set.", + "properties": { + "key": { + "title": "Key", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/PluginSettingFieldType" + }, + "label": { + "title": "Label", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "required": { + "title": "Required", + "type": "boolean" + }, + "value": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Value" + }, + "default": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Default" + }, + "choices": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Choices" + }, + "env": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Env" + }, + "has_value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Has Value" + } + }, + "required": [ + "key", + "type", + "label", + "description", + "required" + ], + "title": "PluginSettingField", + "type": "object" + }, + "PluginSettingFieldType": { + "enum": [ + "string", + "number", + "boolean", + "enum", + "secret", + "json" + ], + "title": "PluginSettingFieldType", + "type": "string" + }, + "PluginsAction": { + "enum": [ + "list", + "toggle", + "install", + "update", + "remove", + "settings", + "onboarding" + ], + "title": "PluginsAction", + "type": "string" + }, + "PluginsListParams": { + "additionalProperties": false, + "properties": {}, + "title": "PluginsListParams", + "type": "object" + }, + "PluginsListResult": { + "additionalProperties": false, + "properties": { + "plugins": { + "items": { + "$ref": "#/components/schemas/LegacyPluginRow" + }, + "title": "Plugins", + "type": "array" + } + }, + "required": [ + "plugins" + ], + "title": "PluginsListResult", + "type": "object" + }, + "PluginsManageParams": { + "additionalProperties": false, + "description": "``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name``\n(+ ``force``, ``enable``, ``ref``); ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin\nthat widened the plugin after the user confirmed the ``delta``); ``remove``: ``name`` (user installs only);\n``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "action": { + "$ref": "#/components/schemas/PluginsAction", + "default": "list" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "enable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enable" + }, + "identifier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identifier" + }, + "repo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repo" + }, + "catalog_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Catalog Name" + }, + "force": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Force" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref" + }, + "accept_capabilities": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Accept Capabilities" + }, + "values": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Values" + } + }, + "title": "PluginsManageParams", + "type": "object" + }, + "PluginsManageResult": { + "additionalProperties": false, + "description": "``list`` \u2192 ``plugins`` + counts; ``toggle`` \u2192 ``ok``/``unchanged``/``restart_required``/``name``\n(the canonical key written)/``plugin``; ``install`` \u2192 ``hermes_cli.plugins_cmd.dashboard_install_plugin``'s\nok payload; ``toggle``/``install``/``update`` that loaded a plugin also carry ``gateway_reloaded`` (the\nrunning gateway picked it up and re-wired its handlers) and ``activation`` \u2014 the honest split of what is\nlive now vs deferred, so ``restart_required`` is True only when no gateway answered; ``update`` \u2192 ``ok``/``unchanged``/``sha``, or ``ok=false`` + ``consent_required`` with the\n``delta`` (``{surface: [added...]}``) / ``delta_lines`` a widened pin adds \u2014 nothing changed until the\nclient retries with ``accept_capabilities``; ``remove`` \u2192 ``ok``/``name`` plus\n``cleared_memory_provider`` when the removed plugin was the live ``memory.provider``.", + "properties": { + "plugins": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/AgentPluginRow" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Plugins" + }, + "user_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "User Count" + }, + "bundled_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Bundled Count" + }, + "ok": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ok" + }, + "unchanged": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unchanged" + }, + "restart_required": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Restart Required" + }, + "gateway_reloaded": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Gateway Reloaded" + }, + "activation": { + "anyOf": [ + { + "$ref": "#/components/schemas/PluginActivation" + }, + { + "type": "null" + } + ], + "default": null + }, + "cleared_memory_provider": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cleared Memory Provider" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "plugin": { + "anyOf": [ + { + "$ref": "#/components/schemas/AgentPluginRow" + }, + { + "type": "null" + } + ], + "default": null + }, + "plugin_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Plugin Name" + }, + "warnings": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Warnings" + }, + "missing_env": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Missing Env" + }, + "python_dependencies": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Python Dependencies" + }, + "known_issues": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Known Issues" + }, + "after_install_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "After Install Path" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled" + }, + "sha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sha" + }, + "consent_required": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Consent Required" + }, + "delta": { + "anyOf": [ + { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Delta" + }, + "delta_lines": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Delta Lines" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "written": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Written" + }, + "onboarding": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/OnboardingCatalogPlugin" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Onboarding" + } + }, + "title": "PluginsManageResult", + "type": "object" + }, + "PreviewActRequestParams": { + "additionalProperties": false, + "description": "``tools/drive_preview_tool.py`` and ``tools/annotate_preview_tool.py`` field sets.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "action": { + "title": "Action", + "type": "string" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref" + }, + "selector": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Selector" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key" + }, + "submit": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Submit" + }, + "full": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Full" + }, + "to": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "To" + }, + "amount": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Amount" + }, + "max": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max" + }, + "allow_shortcut": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Allow Shortcut" + } + }, + "required": [ + "session_id", + "action" + ], + "title": "PreviewActRequestParams", + "type": "object" + }, + "PreviewClosePayload": { + "additionalProperties": false, + "description": "``tools/preview_tool.py`` / ``tools/close_preview_tool.py``; ``url`` '' closes every tab.", + "properties": { + "url": { + "default": "", + "title": "Url", + "type": "string" + } + }, + "title": "PreviewClosePayload", + "type": "object" + }, + "PreviewOpenPayload": { + "additionalProperties": false, + "description": "``tools/open_preview_tool.py``.", + "properties": { + "url": { + "title": "Url", + "type": "string" + }, + "label": { + "default": "", + "title": "Label", + "type": "string" + } + }, + "required": [ + "url" + ], + "title": "PreviewOpenPayload", + "type": "object" + }, + "PreviewRestartParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "url": { + "title": "Url", + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "context": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context" + } + }, + "required": [ + "session_id", + "url" + ], + "title": "PreviewRestartParams", + "type": "object" + }, + "PreviewRestartProgressPayload": { + "additionalProperties": false, + "description": "``methods_prompt`` restart body + ``agent_callbacks._preview_restart_callbacks``.", + "properties": { + "task_id": { + "title": "Task Id", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + }, + "level": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Level" + } + }, + "required": [ + "task_id", + "text" + ], + "title": "PreviewRestartProgressPayload", + "type": "object" + }, + "ProcessEntry": { + "additionalProperties": true, + "description": "``tools/process_registry.py::list_sessions`` row plus the gateway's ``output_tail``.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "command": { + "default": "", + "title": "Command", + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "pid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pid" + }, + "owner_task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Owner Task Id" + }, + "started_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "uptime_seconds": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Uptime Seconds" + }, + "status": { + "default": "running", + "title": "Status", + "type": "string" + }, + "output_preview": { + "default": "", + "title": "Output Preview", + "type": "string" + }, + "output_tail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Output Tail" + }, + "session_scoped": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Scoped" + }, + "watch_patterns": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Watch Patterns" + }, + "watch_hit": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Watch Hit" + }, + "notify_on_complete": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Notify On Complete" + }, + "exit_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Exit Code" + }, + "exited_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Exited At" + }, + "completion_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Completion Reason" + }, + "detached": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Detached" + } + }, + "required": [ + "session_id" + ], + "title": "ProcessEntry", + "type": "object" + }, + "ProcessKillParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "process_id": { + "title": "Process Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id", + "process_id" + ], + "title": "ProcessKillParams", + "type": "object" + }, + "ProcessKillResult": { + "additionalProperties": true, + "description": "``tools/process_registry.py::kill_process`` snapshot; ``error`` rides on the failure statuses.", + "properties": { + "status": { + "$ref": "#/components/schemas/ProcessKillStatus" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "exit_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Exit Code" + }, + "completion_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Completion Reason" + }, + "termination_source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Termination Source" + }, + "output": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Output" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + } + }, + "required": [ + "status" + ], + "title": "ProcessKillResult", + "type": "object" + }, + "ProcessKillStatus": { + "enum": [ + "killed", + "already_exited", + "not_found", + "error" + ], + "title": "ProcessKillStatus", + "type": "string" + }, + "ProcessListParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "ProcessListParams", + "type": "object" + }, + "ProcessListResult": { + "additionalProperties": false, + "properties": { + "processes": { + "items": { + "$ref": "#/components/schemas/ProcessEntry" + }, + "title": "Processes", + "type": "array" + } + }, + "title": "ProcessListResult", + "type": "object" + }, + "ProcessStopParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "ProcessStopParams", + "type": "object" + }, + "ProcessStopResult": { + "additionalProperties": false, + "properties": { + "killed": { + "title": "Killed", + "type": "integer" + } + }, + "required": [ + "killed" + ], + "title": "ProcessStopResult", + "type": "object" + }, + "ProfileCanonicalSession": { + "additionalProperties": false, + "description": "The profile's \"Bot Chat\" registry row; ``resolved_id`` is the live compression tip.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "resolved_id": { + "title": "Resolved Id", + "type": "string" + }, + "root_title": { + "default": "", + "title": "Root Title", + "type": "string" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "preview": { + "default": "", + "title": "Preview", + "type": "string" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + } + ], + "default": 0, + "title": "Started At" + }, + "last_active": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + } + ], + "default": 0, + "title": "Last Active" + }, + "message_count": { + "default": 0, + "title": "Message Count", + "type": "integer" + } + }, + "required": [ + "id", + "resolved_id" + ], + "title": "ProfileCanonicalSession", + "type": "object" + }, + "ProfileMirrored": { + "additionalProperties": false, + "description": "What was copied from the launch profile; ``auth`` is ``\"shared\"`` under ``share_auth``.", + "properties": { + "env": { + "default": false, + "title": "Env", + "type": "boolean" + }, + "auth": { + "anyOf": [ + { + "type": "boolean" + }, + { + "const": "shared", + "type": "string" + } + ], + "default": false, + "title": "Auth" + }, + "model_inherited": { + "default": false, + "title": "Model Inherited", + "type": "boolean" + }, + "voice": { + "default": false, + "title": "Voice", + "type": "boolean" + } + }, + "title": "ProfileMirrored", + "type": "object" + }, + "ProfileModelPin": { + "additionalProperties": false, + "properties": { + "provider": { + "default": "", + "title": "Provider", + "type": "string" + }, + "default": { + "type": "string", + "default": "", + "title": "Default" + } + }, + "title": "ProfileModelPin", + "type": "object" + }, + "ProfileNameParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + } + }, + "title": "ProfileNameParams", + "type": "object" + }, + "ProfileParams": { + "additionalProperties": false, + "description": "Any method the desktop may route to a named profile (``requestGatewayForProfile`` adds ``profile``).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "ProfileParams", + "type": "object" + }, + "ProfileRow": { + "additionalProperties": false, + "description": "One roster row; the session fields are present only with ``include_sessions``.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "is_default": { + "default": false, + "title": "Is Default", + "type": "boolean" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "display_name": { + "default": "", + "title": "Display Name", + "type": "string" + }, + "skill_count": { + "default": 0, + "title": "Skill Count", + "type": "integer" + }, + "previous_names": { + "items": { + "type": "string" + }, + "title": "Previous Names", + "type": "array" + }, + "role": { + "anyOf": [ + { + "const": "setup", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Role" + }, + "last_session": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProfileSessionPreview" + }, + { + "type": "null" + } + ], + "default": null + }, + "worker_session": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProfileWorkerSession" + }, + { + "type": "null" + } + ], + "default": null + }, + "canonical_session": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProfileCanonicalSession" + }, + { + "type": "null" + } + ], + "default": null + }, + "ui_meta_revisions": { + "additionalProperties": { + "type": "integer" + }, + "title": "Ui Meta Revisions", + "type": "object" + }, + "ui_meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ui Meta" + }, + "has_avatar": { + "default": false, + "title": "Has Avatar", + "type": "boolean" + } + }, + "required": [ + "name", + "path" + ], + "title": "ProfileRow", + "type": "object" + }, + "ProfileSessionPreview": { + "additionalProperties": false, + "description": "Newest human-facing session of a profile (``_latest_profile_session_rows``).", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "preview": { + "default": "", + "title": "Preview", + "type": "string" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + } + ], + "default": 0, + "title": "Started At" + }, + "last_active": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + } + ], + "default": 0, + "title": "Last Active" + }, + "message_count": { + "default": 0, + "title": "Message Count", + "type": "integer" + } + }, + "required": [ + "id" + ], + "title": "ProfileSessionPreview", + "type": "object" + }, + "ProfileWorkerSession": { + "additionalProperties": false, + "description": "Newest kanban/tool worker row, so rosters can show a profile as working.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "source": { + "default": "", + "title": "Source", + "type": "string" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "last_active": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + } + ], + "default": 0, + "title": "Last Active" + } + }, + "required": [ + "id" + ], + "title": "ProfileWorkerSession", + "type": "object" + }, + "ProfilesConfigureApplied": { + "additionalProperties": false, + "description": "Per-section outcome; only the sections the request carried are present.", + "properties": { + "ui_meta": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ui Meta" + }, + "ui_meta_revisions": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ui Meta Revisions" + }, + "ui_meta_conflicts": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/UiMetaConflict" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ui Meta Conflicts" + }, + "soul": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Soul" + }, + "description": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "model": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "skills": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skills" + }, + "toolsets": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Toolsets" + }, + "mcp_servers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mcp Servers" + } + }, + "title": "ProfilesConfigureApplied", + "type": "object" + }, + "ProfilesConfigureParams": { + "additionalProperties": false, + "description": "Sections are independent; ``ui_meta_expected_revisions`` is a per-key compare-and-swap.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "ui_meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ui Meta" + }, + "ui_meta_expected_revisions": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ui Meta Expected Revisions" + }, + "soul": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Soul" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "confirm_expensive_model": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm Expensive Model" + }, + "disabled_skills": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Disabled Skills" + }, + "enabled_toolsets": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled Toolsets" + }, + "enabled_mcp_servers": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled Mcp Servers" + } + }, + "title": "ProfilesConfigureParams", + "type": "object" + }, + "ProfilesConfigureResult": { + "additionalProperties": false, + "description": "``confirm_required`` mirrors ``config.set``: a guarded model pick wrote nothing yet.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "applied": { + "$ref": "#/components/schemas/ProfilesConfigureApplied" + }, + "confirm_required": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm Required" + }, + "confirm_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm Message" + } + }, + "required": [ + "ok", + "applied" + ], + "title": "ProfilesConfigureResult", + "type": "object" + }, + "ProfilesCreateParams": { + "additionalProperties": false, + "description": "``clone_from`` omitted = fresh profile + bundled skills; ``mirror_credentials`` defaults on\nso a headless bot has a provider.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "clone_from": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clone From" + }, + "clone_all": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clone All" + }, + "clone_channels": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clone Channels" + }, + "no_skills": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "No Skills" + }, + "no_alias": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "No Alias" + }, + "soul": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Soul" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "share_auth": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Share Auth" + }, + "mirror_credentials": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mirror Credentials" + } + }, + "required": [ + "name" + ], + "title": "ProfilesCreateParams", + "type": "object" + }, + "ProfilesCreateResult": { + "additionalProperties": false, + "properties": { + "ok": { + "default": true, + "title": "Ok", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "soul_written": { + "default": false, + "title": "Soul Written", + "type": "boolean" + }, + "model_set": { + "default": false, + "title": "Model Set", + "type": "boolean" + }, + "mirrored": { + "$ref": "#/components/schemas/ProfileMirrored" + } + }, + "required": [ + "name", + "path", + "mirrored" + ], + "title": "ProfilesCreateResult", + "type": "object" + }, + "ProfilesDescribeResult": { + "additionalProperties": false, + "description": "Editor snapshot; ``toolsets_pinned`` says whether ``tools.enabled_toolsets`` is explicit.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "soul": { + "default": "", + "title": "Soul", + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/ProfileModelPin" + }, + "skills": { + "items": { + "$ref": "#/components/schemas/CapabilityEntry" + }, + "title": "Skills", + "type": "array" + }, + "toolsets": { + "items": { + "$ref": "#/components/schemas/ToolsetEntry" + }, + "title": "Toolsets", + "type": "array" + }, + "toolsets_pinned": { + "default": false, + "title": "Toolsets Pinned", + "type": "boolean" + }, + "mcp_servers": { + "items": { + "$ref": "#/components/schemas/McpServerEntry" + }, + "title": "Mcp Servers", + "type": "array" + } + }, + "required": [ + "name", + "model" + ], + "title": "ProfilesDescribeResult", + "type": "object" + }, + "ProfilesGetAssetParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "asset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Asset" + } + }, + "title": "ProfilesGetAssetParams", + "type": "object" + }, + "ProfilesGetAssetResult": { + "additionalProperties": false, + "description": "Absent is ``found: false``, not an error.", + "properties": { + "found": { + "title": "Found", + "type": "boolean" + }, + "mime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mime" + }, + "size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Size" + }, + "data": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data" + } + }, + "required": [ + "found" + ], + "title": "ProfilesGetAssetResult", + "type": "object" + }, + "ProfilesListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "include_sessions": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Sessions" + } + }, + "title": "ProfilesListParams", + "type": "object" + }, + "ProfilesListResult": { + "additionalProperties": false, + "description": "``bot_mode_protocol`` tells clients this backend injects the teammate protocol itself.", + "properties": { + "profiles": { + "items": { + "$ref": "#/components/schemas/ProfileRow" + }, + "title": "Profiles", + "type": "array" + }, + "bot_mode_protocol": { + "default": true, + "title": "Bot Mode Protocol", + "type": "boolean" + } + }, + "title": "ProfilesListResult", + "type": "object" + }, + "ProfilesRememberOnboardingParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "answers": { + "anyOf": [ + { + "$ref": "#/components/schemas/OnboardingAnswers" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "ProfilesRememberOnboardingParams", + "type": "object" + }, + "ProfilesRememberOnboardingResult": { + "additionalProperties": false, + "properties": { + "saved": { + "default": true, + "title": "Saved", + "type": "boolean" + }, + "profile": { + "default": "default", + "title": "Profile", + "type": "string" + }, + "target": { + "default": "user", + "title": "Target", + "type": "string" + } + }, + "title": "ProfilesRememberOnboardingResult", + "type": "object" + }, + "ProfilesSetAssetParams": { + "additionalProperties": false, + "description": "``data`` is a data URL or bare base64 (PNG/JPEG/WebP, sniffed); ``clear`` deletes instead.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "asset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Asset" + }, + "data": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data" + }, + "clear": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clear" + } + }, + "title": "ProfilesSetAssetParams", + "type": "object" + }, + "ProfilesSetAssetResult": { + "additionalProperties": false, + "properties": { + "ok": { + "default": true, + "title": "Ok", + "type": "boolean" + }, + "asset": { + "title": "Asset", + "type": "string" + }, + "size": { + "default": 0, + "title": "Size", + "type": "integer" + }, + "removed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Removed" + } + }, + "required": [ + "asset" + ], + "title": "ProfilesSetAssetResult", + "type": "object" + }, + "ProjectFacts": { + "additionalProperties": false, + "description": "``agent/coding_context.py::project_facts_for`` \u2014 the system prompt's coding-context detection.", + "properties": { + "root": { + "title": "Root", + "type": "string" + }, + "manifests": { + "items": { + "type": "string" + }, + "title": "Manifests", + "type": "array" + }, + "packageManagers": { + "items": { + "type": "string" + }, + "title": "Packagemanagers", + "type": "array" + }, + "verifyCommands": { + "items": { + "type": "string" + }, + "title": "Verifycommands", + "type": "array" + }, + "contextFiles": { + "items": { + "type": "string" + }, + "title": "Contextfiles", + "type": "array" + } + }, + "required": [ + "root", + "manifests", + "packageManagers", + "verifyCommands", + "contextFiles" + ], + "title": "ProjectFacts", + "type": "object" + }, + "ProjectFactsParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + } + }, + "title": "ProjectFactsParams", + "type": "object" + }, + "ProjectFactsResult": { + "additionalProperties": false, + "description": "``facts`` null outside a workspace (or when detection failed).", + "properties": { + "facts": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectFacts" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "ProjectFactsResult", + "type": "object" + }, + "ProjectFolder": { + "additionalProperties": false, + "description": "``hermes_cli/projects_db.py::ProjectFolder.to_dict``.", + "properties": { + "path": { + "title": "Path", + "type": "string" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + }, + "is_primary": { + "default": false, + "title": "Is Primary", + "type": "boolean" + }, + "added_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Added At" + } + }, + "required": [ + "path" + ], + "title": "ProjectFolder", + "type": "object" + }, + "ProjectFolderParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "title": "Id", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + } + }, + "required": [ + "id", + "path" + ], + "title": "ProjectFolderParams", + "type": "object" + }, + "ProjectIdParams": { + "additionalProperties": false, + "description": "Any method addressed at one stored project (``5062`` when the id resolves to nothing).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "title": "Id", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "ProjectIdParams", + "type": "object" + }, + "ProjectInfo": { + "additionalProperties": false, + "description": "``hermes_cli/projects_db.py::Project.to_dict`` \u2014 one stored project with its folders.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Icon" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Color" + }, + "board_slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Board Slug" + }, + "primary_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Primary Path" + }, + "archived": { + "default": false, + "title": "Archived", + "type": "boolean" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "folders": { + "items": { + "$ref": "#/components/schemas/ProjectFolder" + }, + "title": "Folders", + "type": "array" + } + }, + "required": [ + "id", + "slug", + "name", + "created_at" + ], + "title": "ProjectInfo", + "type": "object" + }, + "ProjectRef": { + "additionalProperties": false, + "description": "``tui_gateway/server.py::_project_info_for_cwd``.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "primary_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Primary Path" + } + }, + "required": [ + "id", + "slug", + "name" + ], + "title": "ProjectRef", + "type": "object" + }, + "ProjectResult": { + "additionalProperties": false, + "properties": { + "project": { + "$ref": "#/components/schemas/ProjectInfo" + } + }, + "required": [ + "project" + ], + "title": "ProjectResult", + "type": "object" + }, + "ProjectTreeLane": { + "additionalProperties": false, + "description": "One branch / worktree / kanban lane inside a repo; ``sessions`` is empty unless hydrated.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "isMain": { + "default": false, + "title": "Ismain", + "type": "boolean" + }, + "isKanban": { + "default": false, + "title": "Iskanban", + "type": "boolean" + }, + "isGit": { + "default": true, + "title": "Isgit", + "type": "boolean" + }, + "sessions": { + "items": { + "$ref": "#/components/schemas/ProjectTreeSession" + }, + "title": "Sessions", + "type": "array" + } + }, + "required": [ + "id", + "label" + ], + "title": "ProjectTreeLane", + "type": "object" + }, + "ProjectTreeNode": { + "additionalProperties": false, + "description": "``project_tree._project_node`` \u2014 explicit, auto (git root) or the synthetic Home bucket.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Color" + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Icon" + }, + "isAuto": { + "default": false, + "title": "Isauto", + "type": "boolean" + }, + "isNoProject": { + "default": false, + "title": "Isnoproject", + "type": "boolean" + }, + "sessionCount": { + "default": 0, + "title": "Sessioncount", + "type": "integer" + }, + "lastActive": { + "default": 0.0, + "title": "Lastactive", + "type": "number" + }, + "totalTokens": { + "default": 0, + "title": "Totaltokens", + "type": "integer" + }, + "totalCostUsd": { + "default": 0.0, + "title": "Totalcostusd", + "type": "number" + }, + "repos": { + "items": { + "$ref": "#/components/schemas/ProjectTreeRepo" + }, + "title": "Repos", + "type": "array" + }, + "previewSessions": { + "items": { + "$ref": "#/components/schemas/ProjectTreeSession" + }, + "title": "Previewsessions", + "type": "array" + }, + "sessionIds": { + "items": { + "type": "string" + }, + "title": "Sessionids", + "type": "array" + } + }, + "required": [ + "id", + "label" + ], + "title": "ProjectTreeNode", + "type": "object" + }, + "ProjectTreeRepo": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "groups": { + "items": { + "$ref": "#/components/schemas/ProjectTreeLane" + }, + "title": "Groups", + "type": "array" + }, + "sessionCount": { + "default": 0, + "title": "Sessioncount", + "type": "integer" + } + }, + "required": [ + "id", + "label" + ], + "title": "ProjectTreeRepo", + "type": "object" + }, + "ProjectTreeSession": { + "additionalProperties": true, + "description": "``methods_projects._project_tree_row`` + ``project_tree.stamp_profile``: the minimal row the\nsidebar renders, stamped with the profile it belongs to.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "preview": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Preview" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "ended_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ended At" + }, + "last_active": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Active" + }, + "message_count": { + "default": 0, + "title": "Message Count", + "type": "integer" + }, + "tool_call_count": { + "default": 0, + "title": "Tool Call Count", + "type": "integer" + }, + "input_tokens": { + "default": 0, + "title": "Input Tokens", + "type": "integer" + }, + "output_tokens": { + "default": 0, + "title": "Output Tokens", + "type": "integer" + }, + "is_active": { + "default": false, + "title": "Is Active", + "type": "boolean" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "git_branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Git Branch" + }, + "git_repo_root": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Git Repo Root" + }, + "parent_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Session Id" + }, + "pinned": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pinned" + }, + "unread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unread" + }, + "archived": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Archived" + }, + "actual_cost_usd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actual Cost Usd" + }, + "estimated_cost_usd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Estimated Cost Usd" + }, + "handoff_platform": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Handoff Platform" + }, + "handoff_state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Handoff State" + }, + "_lineage_root_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Lineage Root Id" + }, + "_lineage_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Lineage Ids" + }, + "continuation_kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Continuation Kind" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "id" + ], + "title": "ProjectTreeSession", + "type": "object" + }, + "ProjectsAddFolderParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "title": "Id", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + }, + "is_primary": { + "default": false, + "title": "Is Primary", + "type": "boolean" + } + }, + "required": [ + "id", + "path" + ], + "title": "ProjectsAddFolderParams", + "type": "object" + }, + "ProjectsArchiveParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "title": "Id", + "type": "string" + }, + "restore": { + "default": false, + "title": "Restore", + "type": "boolean" + } + }, + "required": [ + "id" + ], + "title": "ProjectsArchiveParams", + "type": "object" + }, + "ProjectsCreateParams": { + "additionalProperties": false, + "description": "``use`` also activates the new project.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + }, + "folders": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Folders" + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Slug" + }, + "primary_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Primary Path" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Icon" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Color" + }, + "board_slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Board Slug" + }, + "use": { + "default": false, + "title": "Use", + "type": "boolean" + } + }, + "required": [ + "name" + ], + "title": "ProjectsCreateParams", + "type": "object" + }, + "ProjectsDiscoverReposParams": { + "additionalProperties": false, + "description": "``scan`` asks the host to walk the policy roots itself (remote-gateway desktop).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "scan": { + "default": false, + "title": "Scan", + "type": "boolean" + } + }, + "title": "ProjectsDiscoverReposParams", + "type": "object" + }, + "ProjectsDiscoverReposResult": { + "additionalProperties": false, + "properties": { + "repos": { + "items": { + "$ref": "#/components/schemas/DiscoveredRepo" + }, + "title": "Repos", + "type": "array" + }, + "discovery_policy": { + "anyOf": [ + { + "$ref": "#/components/schemas/RepoDiscoveryPolicy" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "repos" + ], + "title": "ProjectsDiscoverReposResult", + "type": "object" + }, + "ProjectsForCwdParams": { + "additionalProperties": false, + "description": "Absent ``cwd`` resolves the gateway's default completion cwd.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + } + }, + "title": "ProjectsForCwdParams", + "type": "object" + }, + "ProjectsForCwdResult": { + "additionalProperties": false, + "properties": { + "project": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectInfo" + }, + { + "type": "null" + } + ], + "default": null + }, + "cwd": { + "title": "Cwd", + "type": "string" + }, + "branch": { + "default": "", + "title": "Branch", + "type": "string" + } + }, + "required": [ + "cwd" + ], + "title": "ProjectsForCwdResult", + "type": "object" + }, + "ProjectsPayload": { + "additionalProperties": false, + "description": "``methods_projects._projects_payload``: every project (archived included) + the active id.", + "properties": { + "projects": { + "items": { + "$ref": "#/components/schemas/ProjectInfo" + }, + "title": "Projects", + "type": "array" + }, + "active_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Active Id" + } + }, + "required": [ + "projects" + ], + "title": "ProjectsPayload", + "type": "object" + }, + "ProjectsProjectSessionsParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "project_id": { + "title": "Project Id", + "type": "string" + }, + "session_limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Limit" + } + }, + "required": [ + "project_id" + ], + "title": "ProjectsProjectSessionsParams", + "type": "object" + }, + "ProjectsProjectSessionsResult": { + "additionalProperties": false, + "properties": { + "project": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectTreeNode" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "ProjectsProjectSessionsResult", + "type": "object" + }, + "ProjectsRecordReposParams": { + "additionalProperties": false, + "description": "Repos as ``{root, label}`` objects or bare root strings; entries without a root are skipped.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "repos": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/RecordRepoItem" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repos" + }, + "discovery_policy": { + "anyOf": [ + { + "$ref": "#/components/schemas/RepoDiscoveryPolicyParams" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "ProjectsRecordReposParams", + "type": "object" + }, + "ProjectsRecordReposResult": { + "additionalProperties": false, + "properties": { + "repos": { + "items": { + "$ref": "#/components/schemas/DiscoveredRepo" + }, + "title": "Repos", + "type": "array" + }, + "discovery_policy": { + "anyOf": [ + { + "$ref": "#/components/schemas/RepoDiscoveryPolicy" + }, + { + "type": "null" + } + ], + "default": null + }, + "accepted": { + "title": "Accepted", + "type": "boolean" + } + }, + "required": [ + "repos", + "accepted" + ], + "title": "ProjectsRecordReposResult", + "type": "object" + }, + "ProjectsSetActiveParams": { + "additionalProperties": false, + "description": "No ``id`` (or null) clears the active project.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + } + }, + "title": "ProjectsSetActiveParams", + "type": "object" + }, + "ProjectsTreeParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "preview_limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Preview Limit" + }, + "session_limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Limit" + } + }, + "title": "ProjectsTreeParams", + "type": "object" + }, + "ProjectsTreeResult": { + "additionalProperties": false, + "properties": { + "projects": { + "items": { + "$ref": "#/components/schemas/ProjectTreeNode" + }, + "title": "Projects", + "type": "array" + }, + "active_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Active Id" + }, + "scoped_session_ids": { + "items": { + "type": "string" + }, + "title": "Scoped Session Ids", + "type": "array" + } + }, + "required": [ + "projects" + ], + "title": "ProjectsTreeResult", + "type": "object" + }, + "ProjectsUpdateParams": { + "additionalProperties": false, + "description": "Absent keys are left untouched; ``''`` clears ``color`` / ``icon``.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Icon" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Color" + }, + "board_slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Board Slug" + } + }, + "required": [ + "id" + ], + "title": "ProjectsUpdateParams", + "type": "object" + }, + "PromptSubmitParams": { + "additionalProperties": false, + "description": "``text`` is normally a string; the relay / hosted paths may hand a structured (parts list)\npayload, and the busy path renders it. Truncation (rewind / edit / regenerate) needs explicit\nconsent: ``confirm_truncate`` plus one durable target (``truncate_before_row_id`` preferred,\n``truncate_before_message_id``, or the legacy ``truncate_before_user_ordinal``).", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "text": { + "default": "", + "title": "Text" + }, + "display_kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Kind" + }, + "interrupted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Interrupted" + }, + "queued": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Queued" + }, + "surface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Surface" + }, + "voice_context": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Voice Context" + }, + "title_preview": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title Preview" + }, + "truncate_before_user_ordinal": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Truncate Before User Ordinal" + }, + "truncate_before_row_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Truncate Before Row Id" + }, + "truncate_before_message_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Truncate Before Message Id" + }, + "confirm_truncate": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm Truncate" + }, + "confirm_empty_truncate": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm Empty Truncate" + }, + "rebind_survivor_row_ids": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rebind Survivor Row Ids" + } + }, + "required": [ + "session_id" + ], + "title": "PromptSubmitParams", + "type": "object" + }, + "PromptSubmitResult": { + "additionalProperties": false, + "description": "``status`` is absent only on the typed-stop-phrase reply (``voice_stopped``). After a\ntruncation the survivor row ids let the client rebind its cached ``rowId``s (``None`` map\nentries: drop the cached id). ``turn_isolation`` marks a compute-host dispatch.", + "properties": { + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/PromptSubmitStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "voice_stopped": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Voice Stopped" + }, + "user_row_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "User Row Id" + }, + "survivor_user_row_ids": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Survivor User Row Ids" + }, + "survivor_row_id_map": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Survivor Row Id Map" + }, + "turn_isolation": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Isolation" + } + }, + "title": "PromptSubmitResult", + "type": "object" + }, + "PromptSubmitStatus": { + "enum": [ + "streaming", + "queued", + "steered", + "redirected" + ], + "title": "PromptSubmitStatus", + "type": "string" + }, + "QueuedPrompt": { + "additionalProperties": false, + "properties": { + "user": { + "title": "User", + "type": "string" + } + }, + "required": [ + "user" + ], + "title": "QueuedPrompt", + "type": "object" + }, + "ReactionAuthor": { + "enum": [ + "user", + "agent" + ], + "title": "ReactionAuthor", + "type": "string" + }, + "ReactionPayload": { + "additionalProperties": false, + "description": "``agent_callbacks`` reaction_callback.", + "properties": { + "kind": { + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "ReactionPayload", + "type": "object" + }, + "ReadRangeRequestParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "start": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Start" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + } + }, + "required": [ + "session_id" + ], + "title": "ReadRangeRequestParams", + "type": "object" + }, + "RecordRepoItem": { + "additionalProperties": false, + "properties": { + "root": { + "title": "Root", + "type": "string" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + } + }, + "required": [ + "root" + ], + "title": "RecordRepoItem", + "type": "object" + }, + "RelayAgentRow": { + "additionalProperties": true, + "description": "A roster row the Desktop pushes (``tools/bot_relay.py::_normalize_roster_row``); invalid\nrows are dropped server-side, so the shape stays open.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "handle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Handle" + }, + "connection_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Connection Id" + }, + "connection_label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Connection Label" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "online": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Online" + } + }, + "title": "RelayAgentRow", + "type": "object" + }, + "RelayEnvelope": { + "additionalProperties": true, + "description": "``tools/bot_relay.py::enqueue_envelope``.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "created_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + } + ], + "title": "Created At" + }, + "from_profile": { + "title": "From Profile", + "type": "string" + }, + "from_handle": { + "title": "From Handle", + "type": "string" + }, + "target_connection": { + "title": "Target Connection", + "type": "string" + }, + "target_profile": { + "title": "Target Profile", + "type": "string" + }, + "target_handle": { + "title": "Target Handle", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "id", + "created_at", + "from_profile", + "from_handle", + "target_connection", + "target_profile", + "target_handle", + "message" + ], + "title": "RelayEnvelope", + "type": "object" + }, + "ReloadEnvParams": { + "additionalProperties": false, + "properties": {}, + "title": "ReloadEnvParams", + "type": "object" + }, + "ReloadEnvResult": { + "additionalProperties": false, + "properties": { + "updated": { + "title": "Updated", + "type": "integer" + } + }, + "required": [ + "updated" + ], + "title": "ReloadEnvResult", + "type": "object" + }, + "ReloadMcpParams": { + "additionalProperties": false, + "description": "Without ``confirm`` the handler may answer ``confirm_required`` (per ``approvals.mcp_reload_confirm``);\n``always`` persists the opt-out; ``rev`` is the config revision the caller wants loaded (coalescing).", + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "confirm": { + "default": false, + "title": "Confirm", + "type": "boolean" + }, + "always": { + "default": false, + "title": "Always", + "type": "boolean" + }, + "rev": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rev" + } + }, + "title": "ReloadMcpParams", + "type": "object" + }, + "ReloadMcpResult": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/components/schemas/ReloadMcpStatus" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "loaded_rev": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Loaded Rev" + }, + "coalesced": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Coalesced" + }, + "turn_isolation": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Isolation" + }, + "host_ack": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Host Ack" + } + }, + "required": [ + "status" + ], + "title": "ReloadMcpResult", + "type": "object" + }, + "ReloadMcpStatus": { + "enum": [ + "confirm_required", + "reloaded" + ], + "title": "ReloadMcpStatus", + "type": "string" + }, + "RepoDiscoveryPolicy": { + "additionalProperties": false, + "description": "``methods_projects._repo_discovery_policy`` \u2014 the effective ``desktop.repo_scan_*`` config.", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "roots": { + "items": { + "type": "string" + }, + "title": "Roots", + "type": "array" + }, + "exclude_paths": { + "items": { + "type": "string" + }, + "title": "Exclude Paths", + "type": "array" + } + }, + "required": [ + "enabled", + "roots", + "exclude_paths" + ], + "title": "RepoDiscoveryPolicy", + "type": "object" + }, + "RepoDiscoveryPolicyParams": { + "additionalProperties": false, + "description": "The policy the desktop scanned under (short or ``repo_scan_*`` long keys both accepted).", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled" + }, + "roots": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Roots" + }, + "exclude_paths": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Exclude Paths" + }, + "repo_scan_enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repo Scan Enabled" + }, + "repo_scan_roots": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repo Scan Roots" + }, + "repo_scan_exclude_paths": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repo Scan Exclude Paths" + } + }, + "title": "RepoDiscoveryPolicyParams", + "type": "object" + }, + "RequestAnswerParams": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "result": { + "additionalProperties": true, + "title": "Result", + "type": "object" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "id", + "result" + ], + "title": "RequestAnswerParams", + "type": "object" + }, + "RequestAnswerResult": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/components/schemas/ClarifyLockStatus" + } + }, + "required": [ + "status" + ], + "title": "RequestAnswerResult", + "type": "object" + }, + "RequestCancelPayload": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "method": { + "title": "Method", + "type": "string" + }, + "reason": { + "title": "Reason", + "type": "string" + } + }, + "required": [ + "id", + "method", + "reason" + ], + "title": "RequestCancelPayload", + "type": "object" + }, + "ResumePhaseStatus": { + "enum": [ + "loading", + "complete", + "failed" + ], + "title": "ResumePhaseStatus", + "type": "string" + }, + "ReviewSummaryPayload": { + "additionalProperties": false, + "description": "``server`` background_review_callback.", + "properties": { + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "ReviewSummaryPayload", + "type": "object" + }, + "RollbackCheckpoint": { + "additionalProperties": false, + "properties": { + "hash": { + "default": "", + "title": "Hash", + "type": "string" + }, + "timestamp": { + "default": "", + "title": "Timestamp", + "type": "string" + }, + "message": { + "default": "", + "title": "Message", + "type": "string" + } + }, + "title": "RollbackCheckpoint", + "type": "object" + }, + "RollbackDiffParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "hash": { + "title": "Hash", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id", + "hash" + ], + "title": "RollbackDiffParams", + "type": "object" + }, + "RollbackDiffResult": { + "additionalProperties": false, + "properties": { + "stat": { + "default": "", + "title": "Stat", + "type": "string" + }, + "diff": { + "default": "", + "title": "Diff", + "type": "string" + }, + "rendered": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rendered" + } + }, + "title": "RollbackDiffResult", + "type": "object" + }, + "RollbackListParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "RollbackListParams", + "type": "object" + }, + "RollbackListResult": { + "additionalProperties": false, + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "checkpoints": { + "items": { + "$ref": "#/components/schemas/RollbackCheckpoint" + }, + "title": "Checkpoints", + "type": "array" + } + }, + "required": [ + "enabled" + ], + "title": "RollbackListResult", + "type": "object" + }, + "RollbackRestoreParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "hash": { + "title": "Hash", + "type": "string" + }, + "file_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "File Path" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id", + "hash" + ], + "title": "RollbackRestoreParams", + "type": "object" + }, + "RollbackRestoreResult": { + "additionalProperties": true, + "description": "``tools/checkpoint_manager.py::restore`` outcome; ``history_removed`` is added for a full\n(non-file) restore that also rewound the live transcript.", + "properties": { + "success": { + "title": "Success", + "type": "boolean" + }, + "restored_to": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Restored To" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Directory" + }, + "file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "File" + }, + "restored_files": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Restored Files" + }, + "skipped_user_edits": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skipped User Edits" + }, + "skipped_oversize": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skipped Oversize" + }, + "failed_deletes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Failed Deletes" + }, + "history_removed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "History Removed" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "debug": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Debug" + } + }, + "required": [ + "success" + ], + "title": "RollbackRestoreResult", + "type": "object" + }, + "Room": { + "additionalProperties": false, + "description": "``gateway/hosted_rooms.py::_room_from_row`` plus the branch-only keys ``create`` (legacy\nadoption), ``state`` (``authority_claim``) and ``rename`` (``event``) add.", + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "members": { + "items": { + "$ref": "#/components/schemas/RoomMember" + }, + "title": "Members", + "type": "array" + }, + "authority_gateway_id": { + "title": "Authority Gateway Id", + "type": "string" + }, + "authority_epoch": { + "title": "Authority Epoch", + "type": "integer" + }, + "revision": { + "title": "Revision", + "type": "integer" + }, + "created_at": { + "title": "Created At", + "type": "number" + }, + "updated_at": { + "title": "Updated At", + "type": "number" + }, + "idempotent": { + "default": false, + "title": "Idempotent", + "type": "boolean" + }, + "disbanded_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Disbanded At" + }, + "latest_seq": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Latest Seq" + }, + "adopted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Adopted" + }, + "claim_event": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomEvent" + }, + { + "type": "null" + } + ], + "default": null + }, + "authority_claim": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomEvent" + }, + { + "type": "null" + } + ], + "default": null + }, + "event": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomEvent" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "room_id", + "name", + "members", + "authority_gateway_id", + "authority_epoch", + "revision", + "created_at", + "updated_at" + ], + "title": "Room", + "type": "object" + }, + "RoomActor": { + "additionalProperties": false, + "properties": { + "kind": { + "title": "Kind", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "title": "RoomActor", + "type": "object" + }, + "RoomAuthority": { + "additionalProperties": false, + "properties": { + "gateway_id": { + "title": "Gateway Id", + "type": "string" + }, + "epoch": { + "title": "Epoch", + "type": "integer" + } + }, + "required": [ + "gateway_id", + "epoch" + ], + "title": "RoomAuthority", + "type": "object" + }, + "RoomDriverStatus": { + "additionalProperties": false, + "description": "``HostedRoomService.status(room_id)``; ``pending_actions`` rows are ``{kind: retry, task_id}``\nor the driver's approval action (``kind: approval`` + run/session/approval context).", + "properties": { + "running": { + "title": "Running", + "type": "boolean" + }, + "working": { + "title": "Working", + "type": "boolean" + }, + "blocked": { + "title": "Blocked", + "type": "boolean" + }, + "counts": { + "additionalProperties": { + "type": "integer" + }, + "title": "Counts", + "type": "object" + }, + "pending_actions": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Pending Actions", + "type": "array" + }, + "peer_routes": { + "items": { + "$ref": "#/components/schemas/PeerRouteStatus" + }, + "title": "Peer Routes", + "type": "array" + } + }, + "required": [ + "running", + "working", + "blocked", + "counts", + "pending_actions", + "peer_routes" + ], + "title": "RoomDriverStatus", + "type": "object" + }, + "RoomEvent": { + "additionalProperties": false, + "description": "``gateway/hosted_rooms.py::_event_from_row``.", + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "seq": { + "title": "Seq", + "type": "integer" + }, + "event_id": { + "title": "Event Id", + "type": "string" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "actor": { + "$ref": "#/components/schemas/RoomActor" + }, + "authority_epoch": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Epoch" + }, + "payload": { + "additionalProperties": true, + "title": "Payload", + "type": "object" + }, + "created_at": { + "title": "Created At", + "type": "number" + }, + "idempotent": { + "default": false, + "title": "Idempotent", + "type": "boolean" + } + }, + "required": [ + "room_id", + "seq", + "event_id", + "kind", + "actor", + "payload", + "created_at" + ], + "title": "RoomEvent", + "type": "object" + }, + "RoomExecutionPolicy": { + "additionalProperties": false, + "description": "``gateway/hosted_room_execution_policy.py::execution_policy_mapping``.", + "properties": { + "version": { + "title": "Version", + "type": "integer" + }, + "target_profile": { + "title": "Target Profile", + "type": "string" + }, + "enabled_toolsets": { + "items": { + "type": "string" + }, + "title": "Enabled Toolsets", + "type": "array" + }, + "approval_mode": { + "title": "Approval Mode", + "type": "string" + }, + "max_iterations": { + "title": "Max Iterations", + "type": "integer" + }, + "policy_digest": { + "title": "Policy Digest", + "type": "string" + } + }, + "required": [ + "version", + "target_profile", + "enabled_toolsets", + "approval_mode", + "max_iterations", + "policy_digest" + ], + "title": "RoomExecutionPolicy", + "type": "object" + }, + "RoomLinkCatalog": { + "additionalProperties": false, + "description": "``gateway/hosted_room_peer.py::GatewayRoomCatalog.as_mapping``.", + "properties": { + "installation_id": { + "title": "Installation Id", + "type": "string" + }, + "protocol_versions": { + "items": { + "type": "integer" + }, + "title": "Protocol Versions", + "type": "array" + }, + "link_modes": { + "items": { + "type": "string" + }, + "title": "Link Modes", + "type": "array" + }, + "persistent_process": { + "title": "Persistent Process", + "type": "boolean" + }, + "text": { + "title": "Text", + "type": "boolean" + }, + "attachments": { + "title": "Attachments", + "type": "boolean" + }, + "execution_policy": { + "$ref": "#/components/schemas/RoomExecutionPolicy" + }, + "catalog_digest": { + "title": "Catalog Digest", + "type": "string" + }, + "endpoint": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomLinkEndpoint" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "installation_id", + "protocol_versions", + "link_modes", + "persistent_process", + "text", + "attachments", + "execution_policy", + "catalog_digest" + ], + "title": "RoomLinkCatalog", + "type": "object" + }, + "RoomLinkEndpoint": { + "additionalProperties": false, + "description": "``GatewayRoomCatalog.endpoint_mapping``: ``url``/``transport_security`` when available,\n``reason`` when not.", + "properties": { + "available": { + "title": "Available", + "type": "boolean" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "transport_security": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Transport Security" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "available" + ], + "title": "RoomLinkEndpoint", + "type": "object" + }, + "RoomLinkStatus": { + "additionalProperties": false, + "description": "``enabled`` with ``profile``/``catalog``/``endpoint``, or disabled with a ``reason``.", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "catalog": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomLinkCatalog" + }, + { + "type": "null" + } + ], + "default": null + }, + "endpoint": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomLinkEndpoint" + }, + { + "type": "null" + } + ], + "default": null + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "enabled" + ], + "title": "RoomLinkStatus", + "type": "object" + }, + "RoomMember": { + "additionalProperties": true, + "description": "One roster row (``hosted_room_discussion.validate_roster``); legacy rooms may carry\npre-normalisation rows, so the set stays open.", + "properties": { + "member_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Member Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "handle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Handle" + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Name" + }, + "target": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target" + } + }, + "title": "RoomMember", + "type": "object" + }, + "RoomMemberInput": { + "additionalProperties": true, + "description": "A roster row as the client proposes it; ``validate_roster`` owns the exact rules.", + "properties": { + "member_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Member Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "handle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Handle" + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Name" + }, + "target": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target" + } + }, + "title": "RoomMemberInput", + "type": "object" + }, + "RoomTaskReceipt": { + "additionalProperties": false, + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "task_id": { + "title": "Task Id", + "type": "string" + }, + "thread_id": { + "title": "Thread Id", + "type": "string" + }, + "turn_id": { + "title": "Turn Id", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "execution_generation": { + "title": "Execution Generation", + "type": "integer" + }, + "cancel_generation": { + "title": "Cancel Generation", + "type": "integer" + } + }, + "required": [ + "room_id", + "task_id", + "thread_id", + "turn_id", + "status", + "execution_generation", + "cancel_generation" + ], + "title": "RoomTaskReceipt", + "type": "object" + }, + "RoomTombstone": { + "additionalProperties": false, + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "disbanded_at": { + "title": "Disbanded At", + "type": "number" + }, + "idempotent": { + "title": "Idempotent", + "type": "boolean" + }, + "history_expired": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "History Expired" + }, + "event": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomEvent" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "room_id", + "disbanded_at", + "idempotent" + ], + "title": "RoomTombstone", + "type": "object" + }, + "SecretRequestParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "env_var": { + "title": "Env Var", + "type": "string" + }, + "prompt": { + "title": "Prompt", + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Metadata" + } + }, + "required": [ + "session_id", + "env_var", + "prompt" + ], + "title": "SecretRequestParams", + "type": "object" + }, + "SeedMessage": { + "additionalProperties": true, + "description": "One create-time transcript row (``session_history._coerce_seed_history``); ``text`` is the\nlegacy alias of ``content``; only ``display_kind: \"hidden\"`` is accepted from the wire. Clients\nforward stored rows verbatim (``_row_id``, ``timestamp``, \u2026) and the coercer drops what it does\nnot use, so the row stays open.", + "properties": { + "role": { + "title": "Role", + "type": "string" + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "display_kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Kind" + } + }, + "required": [ + "role" + ], + "title": "SeedMessage", + "type": "object" + }, + "SessionActivateParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + }, + "omit_messages": { + "default": false, + "title": "Omit Messages", + "type": "boolean" + } + }, + "required": [ + "session_id" + ], + "title": "SessionActivateParams", + "type": "object" + }, + "SessionActivateResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "message_count": { + "title": "Message Count", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/components/schemas/TranscriptMessage" + }, + "title": "Messages", + "type": "array" + }, + "info": { + "$ref": "#/components/schemas/SessionLiveInfo" + }, + "stored_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stored Session Id" + }, + "resumed": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resumed" + }, + "session_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Key" + }, + "messages_omitted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Messages Omitted" + }, + "hydrating": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hydrating" + }, + "running": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Running" + }, + "turn_started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Started At" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "inflight": { + "anyOf": [ + { + "$ref": "#/components/schemas/InflightTurn" + }, + { + "type": "null" + } + ], + "default": null + }, + "queued": { + "anyOf": [ + { + "$ref": "#/components/schemas/QueuedPrompt" + }, + { + "type": "null" + } + ], + "default": null + }, + "pending_approval": { + "anyOf": [ + { + "$ref": "#/components/schemas/PendingApproval" + }, + { + "type": "null" + } + ], + "default": null + }, + "open_requests": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/OpenRequestEntry" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Open Requests" + }, + "pending_connection": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionRequestPayload" + }, + { + "type": "null" + } + ], + "default": null + }, + "todo_state": { + "anyOf": [ + { + "$ref": "#/components/schemas/TodoState" + }, + { + "type": "null" + } + ], + "default": null + }, + "auto_continue": { + "anyOf": [ + { + "$ref": "#/components/schemas/AutoContinue" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "session_id", + "message_count", + "messages", + "info" + ], + "title": "SessionActivateResult", + "type": "object" + }, + "SessionActiveItem": { + "additionalProperties": false, + "description": "``server._session_live_item``.", + "properties": { + "current": { + "title": "Current", + "type": "boolean" + }, + "id": { + "title": "Id", + "type": "string" + }, + "last_active": { + "title": "Last Active", + "type": "number" + }, + "message_count": { + "title": "Message Count", + "type": "integer" + }, + "model": { + "title": "Model", + "type": "string" + }, + "preview": { + "title": "Preview", + "type": "string" + }, + "session_key": { + "title": "Session Key", + "type": "string" + }, + "started_at": { + "title": "Started At", + "type": "number" + }, + "status": { + "$ref": "#/components/schemas/LiveSessionStatus" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "current", + "id", + "last_active", + "message_count", + "model", + "preview", + "session_key", + "started_at", + "status", + "title" + ], + "title": "SessionActiveItem", + "type": "object" + }, + "SessionActiveListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "current_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Current Session Id" + } + }, + "title": "SessionActiveListParams", + "type": "object" + }, + "SessionActiveListResult": { + "additionalProperties": false, + "properties": { + "sessions": { + "items": { + "$ref": "#/components/schemas/SessionActiveItem" + }, + "title": "Sessions", + "type": "array" + } + }, + "required": [ + "sessions" + ], + "title": "SessionActiveListResult", + "type": "object" + }, + "SessionArchiveParams": { + "additionalProperties": false, + "description": "``session_id`` (or its ``session_key`` alias) is a live runtime id first, else a stored id / key / title.", + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "session_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Key" + }, + "archived": { + "default": true, + "title": "Archived", + "type": "boolean" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "SessionArchiveParams", + "type": "object" + }, + "SessionArchiveResult": { + "additionalProperties": false, + "properties": { + "archived": { + "title": "Archived", + "type": "boolean" + }, + "session_key": { + "title": "Session Key", + "type": "string" + } + }, + "required": [ + "archived", + "session_key" + ], + "title": "SessionArchiveResult", + "type": "object" + }, + "SessionBranchParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + } + }, + "required": [ + "session_id" + ], + "title": "SessionBranchParams", + "type": "object" + }, + "SessionBranchResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "stored_session_id": { + "title": "Stored Session Id", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + }, + "parent": { + "title": "Parent", + "type": "string" + }, + "message_count": { + "title": "Message Count", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/components/schemas/TranscriptMessage" + }, + "title": "Messages", + "type": "array" + }, + "info": { + "$ref": "#/components/schemas/SessionLiveInfo" + } + }, + "required": [ + "session_id", + "stored_session_id", + "title", + "parent", + "message_count", + "messages", + "info" + ], + "title": "SessionBranchResult", + "type": "object" + }, + "SessionBranchStoredParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "parent_session_id": { + "minLength": 1, + "title": "Parent Session Id", + "type": "string" + }, + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + } + }, + "required": [ + "parent_session_id" + ], + "title": "SessionBranchStoredParams", + "type": "object" + }, + "SessionBranchStoredResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "stored_session_id": { + "title": "Stored Session Id", + "type": "string" + }, + "message_count": { + "title": "Message Count", + "type": "integer" + }, + "messages_omitted": { + "title": "Messages Omitted", + "type": "boolean" + }, + "info": { + "$ref": "#/components/schemas/SessionLiveInfo" + } + }, + "required": [ + "session_id", + "stored_session_id", + "message_count", + "messages_omitted", + "info" + ], + "title": "SessionBranchStoredResult", + "type": "object" + }, + "SessionBranchWholeParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + } + }, + "required": [ + "session_id" + ], + "title": "SessionBranchWholeParams", + "type": "object" + }, + "SessionBranchWholeResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "stored_session_id": { + "title": "Stored Session Id", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + }, + "parent": { + "title": "Parent", + "type": "string" + }, + "message_count": { + "title": "Message Count", + "type": "integer" + }, + "messages_omitted": { + "title": "Messages Omitted", + "type": "boolean" + }, + "info": { + "$ref": "#/components/schemas/SessionLiveInfo" + } + }, + "required": [ + "session_id", + "stored_session_id", + "title", + "parent", + "message_count", + "messages_omitted", + "info" + ], + "title": "SessionBranchWholeResult", + "type": "object" + }, + "SessionCloseParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionCloseParams", + "type": "object" + }, + "SessionCloseResult": { + "additionalProperties": false, + "properties": { + "closed": { + "title": "Closed", + "type": "boolean" + } + }, + "required": [ + "closed" + ], + "title": "SessionCloseResult", + "type": "object" + }, + "SessionCompressParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "focus_topic": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Focus Topic" + } + }, + "required": [ + "session_id" + ], + "title": "SessionCompressParams", + "type": "object" + }, + "SessionCompressResult": { + "additionalProperties": true, + "description": "In-process: the before/after summary + replacement transcript. Compute host: its result passes\nthrough (hence open) with ``turn_isolation``; a lock held elsewhere answers ``compressed: false``.", + "properties": { + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "removed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Removed" + }, + "before_messages": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Before Messages" + }, + "after_messages": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "After Messages" + }, + "before_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Before Tokens" + }, + "after_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "After Tokens" + }, + "summary": { + "anyOf": [ + { + "$ref": "#/components/schemas/CompressionSummary" + }, + { + "type": "null" + } + ], + "default": null + }, + "usage": { + "anyOf": [ + { + "$ref": "#/components/schemas/Usage" + }, + { + "type": "null" + } + ], + "default": null + }, + "info": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionLiveInfo" + }, + { + "type": "null" + } + ], + "default": null + }, + "messages": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/TranscriptMessage" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Messages" + }, + "compressed": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Compressed" + }, + "lock_held": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Lock Held" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "turn_isolation": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Isolation" + }, + "host_ack": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Host Ack" + } + }, + "title": "SessionCompressResult", + "type": "object" + }, + "SessionContextBreakdownParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionContextBreakdownParams", + "type": "object" + }, + "SessionContextBreakdownResult": { + "additionalProperties": false, + "description": "``agent.context_breakdown.compute_session_context_breakdown`` (empty categories before the agent builds)\nplus the per-file context manifest (empty until the agent exists).", + "properties": { + "categories": { + "items": { + "$ref": "#/components/schemas/ContextCategory" + }, + "title": "Categories", + "type": "array" + }, + "context_max": { + "title": "Context Max", + "type": "integer" + }, + "context_percent": { + "title": "Context Percent", + "type": "integer" + }, + "context_used": { + "title": "Context Used", + "type": "integer" + }, + "estimated_total": { + "title": "Estimated Total", + "type": "integer" + }, + "context_estimated": { + "title": "Context Estimated", + "type": "boolean" + }, + "context_source": { + "title": "Context Source", + "type": "string" + }, + "model": { + "title": "Model", + "type": "string" + }, + "context_files": { + "default": [], + "items": { + "$ref": "#/components/schemas/ContextFileSource" + }, + "title": "Context Files", + "type": "array" + } + }, + "required": [ + "categories", + "context_max", + "context_percent", + "context_used", + "estimated_total", + "context_estimated", + "context_source", + "model" + ], + "title": "SessionContextBreakdownResult", + "type": "object" + }, + "SessionControlArgs": { + "additionalProperties": false, + "description": "``subgoal.add`` reads ``text``; ``subgoal.remove`` reads the 1-based ``index``.", + "properties": { + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Index" + } + }, + "title": "SessionControlArgs", + "type": "object" + }, + "SessionControlDispatch": { + "additionalProperties": false, + "description": "``_dispatch_envelope`` \u2014 the command result's user-visible envelope, every key always present.", + "properties": { + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type" + }, + "output": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Output" + }, + "notice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Notice" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Display" + } + }, + "required": [ + "type", + "output", + "notice", + "message", + "display" + ], + "title": "SessionControlDispatch", + "type": "object" + }, + "SessionControlParams": { + "additionalProperties": false, + "description": "``action`` is validated by the handler (unknown / gate actions answer ``4004``), so it stays a\nstring on the wire; ``SessionControlAction`` lists the accepted set.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "session_id": { + "title": "Session Id", + "type": "string" + }, + "action": { + "title": "Action", + "type": "string" + }, + "args": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionControlArgs" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "session_id", + "action" + ], + "title": "SessionControlParams", + "type": "object" + }, + "SessionControlReadParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "session_id": { + "title": "Session Id", + "type": "string" + } + }, + "required": [ + "session_id" + ], + "title": "SessionControlReadParams", + "type": "object" + }, + "SessionControlReadResult": { + "additionalProperties": false, + "properties": { + "control": { + "$ref": "#/components/schemas/SessionControlSnapshot" + } + }, + "required": [ + "control" + ], + "title": "SessionControlReadResult", + "type": "object" + }, + "SessionControlResult": { + "additionalProperties": false, + "properties": { + "control": { + "$ref": "#/components/schemas/SessionControlSnapshot" + }, + "dispatch": { + "$ref": "#/components/schemas/SessionControlDispatch" + } + }, + "required": [ + "control", + "dispatch" + ], + "title": "SessionControlResult", + "type": "object" + }, + "SessionControlSnapshot": { + "additionalProperties": false, + "description": "``_snapshot_control`` \u2014 ``revision`` is a hash of the visible state (``\"\"`` when empty);\n``updated_at`` is the newest persisted timestamp (``0`` when none).", + "properties": { + "goal": { + "anyOf": [ + { + "$ref": "#/components/schemas/GoalSnapshot" + }, + { + "type": "null" + } + ] + }, + "loop": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopSnapshot" + }, + { + "type": "null" + } + ] + }, + "heartbeat": { + "anyOf": [ + { + "$ref": "#/components/schemas/HeartbeatSnapshot" + }, + { + "type": "null" + } + ] + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "updated_at": { + "title": "Updated At", + "type": "number" + } + }, + "required": [ + "goal", + "loop", + "heartbeat", + "revision", + "updated_at" + ], + "title": "SessionControlSnapshot", + "type": "object" + }, + "SessionControlUpdatePayload": { + "additionalProperties": false, + "properties": { + "control": { + "$ref": "#/components/schemas/SessionControlSnapshot" + } + }, + "required": [ + "control" + ], + "title": "SessionControlUpdatePayload", + "type": "object" + }, + "SessionCorrectionParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "session_id", + "text" + ], + "title": "SessionCorrectionParams", + "type": "object" + }, + "SessionCorrectionResult": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/components/schemas/CorrectionStatus" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "status", + "text" + ], + "title": "SessionCorrectionResult", + "type": "object" + }, + "SessionCreateParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "cwd_explicit": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd Explicit" + }, + "messages": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SeedMessage" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Messages" + }, + "parent_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Session Id" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "reasoning_effort": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reasoning Effort" + }, + "fast": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Fast" + }, + "close_on_disconnect": { + "default": false, + "title": "Close On Disconnect", + "type": "boolean" + }, + "hidden": { + "default": false, + "title": "Hidden", + "type": "boolean" + }, + "room_plumbing": { + "default": false, + "title": "Room Plumbing", + "type": "boolean" + }, + "follow_profile_config": { + "default": false, + "title": "Follow Profile Config", + "type": "boolean" + } + }, + "title": "SessionCreateParams", + "type": "object" + }, + "SessionCreateResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "stored_session_id": { + "title": "Stored Session Id", + "type": "string" + }, + "message_count": { + "title": "Message Count", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/components/schemas/TranscriptMessage" + }, + "title": "Messages", + "type": "array" + }, + "info": { + "$ref": "#/components/schemas/SessionLiveInfo" + } + }, + "required": [ + "session_id", + "stored_session_id", + "message_count", + "messages", + "info" + ], + "title": "SessionCreateResult", + "type": "object" + }, + "SessionCwdSetParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "cwd": { + "title": "Cwd", + "type": "string" + } + }, + "required": [ + "session_id", + "cwd" + ], + "title": "SessionCwdSetParams", + "type": "object" + }, + "SessionCwdSetResult": { + "additionalProperties": true, + "description": "The refreshed ``session.info`` view (full agent view, or the lazy shape).", + "properties": { + "model": { + "default": "", + "title": "Model", + "type": "string" + }, + "provider": { + "default": "", + "title": "Provider", + "type": "string" + }, + "reasoning_effort": { + "default": "", + "title": "Reasoning Effort", + "type": "string" + }, + "reasoning_effort_wire": { + "default": "", + "title": "Reasoning Effort Wire", + "type": "string" + }, + "service_tier": { + "default": "", + "title": "Service Tier", + "type": "string" + }, + "fast": { + "default": false, + "title": "Fast", + "type": "boolean" + }, + "yolo": { + "default": false, + "title": "Yolo", + "type": "boolean" + }, + "approval_mode": { + "default": "manual", + "title": "Approval Mode", + "type": "string" + }, + "tools": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Tools", + "type": "object" + }, + "skills": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Skills", + "type": "object" + }, + "cwd": { + "default": "", + "title": "Cwd", + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Branch" + }, + "project": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectRef" + }, + { + "type": "null" + } + ], + "default": null + }, + "terminal_backend": { + "default": "", + "title": "Terminal Backend", + "type": "string" + }, + "personality": { + "default": "", + "title": "Personality", + "type": "string" + }, + "running": { + "default": false, + "title": "Running", + "type": "boolean" + }, + "turn_started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Started At" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "stored_session_id": { + "default": "", + "title": "Stored Session Id", + "type": "string" + }, + "desktop_contract": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Desktop Contract" + }, + "version": { + "default": "", + "title": "Version", + "type": "string" + }, + "release_date": { + "default": "", + "title": "Release Date", + "type": "string" + }, + "update_behind": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Update Behind" + }, + "update_command": { + "default": "", + "title": "Update Command", + "type": "string" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/components/schemas/Usage" + }, + { + "type": "null" + } + ], + "default": null + }, + "profile_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile Name" + }, + "mcp_servers": { + "items": { + "$ref": "#/components/schemas/McpServerStatus" + }, + "title": "Mcp Servers", + "type": "array" + }, + "system_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "System Prompt" + }, + "credential_warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Credential Warning" + }, + "lazy": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Lazy" + } + }, + "title": "SessionCwdSetResult", + "type": "object" + }, + "SessionDeleteParams": { + "additionalProperties": false, + "description": "``session_id`` is the STORED id.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionDeleteParams", + "type": "object" + }, + "SessionDeleteResult": { + "additionalProperties": false, + "properties": { + "deleted": { + "title": "Deleted", + "type": "string" + } + }, + "required": [ + "deleted" + ], + "title": "SessionDeleteResult", + "type": "object" + }, + "SessionEventsSinceParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "last_seen": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Seen" + } + }, + "required": [ + "session_id" + ], + "title": "SessionEventsSinceParams", + "type": "object" + }, + "SessionEventsSinceResult": { + "additionalProperties": false, + "properties": { + "events": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Events", + "type": "array" + }, + "latest_seq": { + "title": "Latest Seq", + "type": "integer" + }, + "truncated": { + "title": "Truncated", + "type": "boolean" + }, + "count": { + "title": "Count", + "type": "integer" + }, + "epoch": { + "title": "Epoch", + "type": "string" + }, + "open_requests": { + "items": { + "$ref": "#/components/schemas/OpenRequestEntry" + }, + "title": "Open Requests", + "type": "array" + } + }, + "required": [ + "events", + "latest_seq", + "truncated", + "count", + "epoch", + "open_requests" + ], + "title": "SessionEventsSinceResult", + "type": "object" + }, + "SessionEventsStatsParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "SessionEventsStatsParams", + "type": "object" + }, + "SessionEventsStatsResult": { + "additionalProperties": false, + "description": "``event_replay.replay_stats``.", + "properties": { + "sessions": { + "title": "Sessions", + "type": "integer" + }, + "events": { + "title": "Events", + "type": "integer" + }, + "bytes": { + "title": "Bytes", + "type": "integer" + }, + "max_per_session": { + "title": "Max Per Session", + "type": "integer" + }, + "max_bytes_per_session": { + "title": "Max Bytes Per Session", + "type": "integer" + }, + "max_bytes_process": { + "title": "Max Bytes Process", + "type": "integer" + } + }, + "required": [ + "sessions", + "events", + "bytes", + "max_per_session", + "max_bytes_per_session", + "max_bytes_process" + ], + "title": "SessionEventsStatsResult", + "type": "object" + }, + "SessionForeignIdParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + } + }, + "title": "SessionForeignIdParams", + "type": "object" + }, + "SessionForeignImportResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "already_imported": { + "default": false, + "title": "Already Imported", + "type": "boolean" + } + }, + "required": [ + "session_id" + ], + "title": "SessionForeignImportResult", + "type": "object" + }, + "SessionForeignListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "source": { + "anyOf": [ + { + "$ref": "#/components/schemas/ForeignSource" + }, + { + "type": "null" + } + ], + "default": null + }, + "offset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Offset" + }, + "limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Limit" + } + }, + "title": "SessionForeignListParams", + "type": "object" + }, + "SessionForeignListResult": { + "additionalProperties": false, + "description": "``unreadable`` counts logs on this page that failed to parse.", + "properties": { + "sessions": { + "items": { + "$ref": "#/components/schemas/ForeignSessionRow" + }, + "title": "Sessions", + "type": "array" + }, + "next_offset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Next Offset" + }, + "host": { + "title": "Host", + "type": "string" + }, + "unreadable": { + "default": 0, + "title": "Unreadable", + "type": "integer" + } + }, + "required": [ + "host" + ], + "title": "SessionForeignListResult", + "type": "object" + }, + "SessionForeignPreviewResult": { + "additionalProperties": false, + "description": "Bounded to the last 40 turns / 8000 chars each; ``already_imported`` is the local id.", + "properties": { + "messages": { + "items": { + "$ref": "#/components/schemas/ForeignTurn" + }, + "title": "Messages", + "type": "array" + }, + "total": { + "default": 0, + "title": "Total", + "type": "integer" + }, + "truncated": { + "default": false, + "title": "Truncated", + "type": "boolean" + }, + "already_imported": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Already Imported" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + } + }, + "title": "SessionForeignPreviewResult", + "type": "object" + }, + "SessionHistoryParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionHistoryParams", + "type": "object" + }, + "SessionHistoryResult": { + "additionalProperties": false, + "properties": { + "count": { + "title": "Count", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/components/schemas/TranscriptMessage" + }, + "title": "Messages", + "type": "array" + } + }, + "required": [ + "count", + "messages" + ], + "title": "SessionHistoryResult", + "type": "object" + }, + "SessionInterruptParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "expected_hosted_task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expected Hosted Task Id" + } + }, + "required": [ + "session_id" + ], + "title": "SessionInterruptParams", + "type": "object" + }, + "SessionInterruptResult": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/components/schemas/InterruptStatus" + }, + "interrupted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Interrupted" + }, + "turn_isolation": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Isolation" + } + }, + "required": [ + "status" + ], + "title": "SessionInterruptResult", + "type": "object" + }, + "SessionListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Limit" + }, + "include_hidden": { + "default": false, + "title": "Include Hidden", + "type": "boolean" + } + }, + "title": "SessionListParams", + "type": "object" + }, + "SessionListResult": { + "additionalProperties": false, + "properties": { + "sessions": { + "items": { + "$ref": "#/components/schemas/SessionListRow" + }, + "title": "Sessions", + "type": "array" + } + }, + "required": [ + "sessions" + ], + "title": "SessionListResult", + "type": "object" + }, + "SessionListRow": { + "additionalProperties": false, + "description": "``methods_session._session_row_summary``; ``resolved_id`` only on a title lookup that followed a\ncompression lineage to its tip.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "resolved_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resolved Id" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "preview": { + "default": "", + "title": "Preview", + "type": "string" + }, + "started_at": { + "default": 0, + "title": "Started At", + "type": "number" + }, + "message_count": { + "default": 0, + "title": "Message Count", + "type": "integer" + }, + "source": { + "default": "", + "title": "Source", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "SessionListRow", + "type": "object" + }, + "SessionLiveInfo": { + "additionalProperties": true, + "description": "``tui_gateway/server.py::_session_info`` \u2014 the ``session.info`` event and the ``info`` field of\n``session.create`` / ``session.resume`` / ``session.activate`` results.", + "properties": { + "model": { + "default": "", + "title": "Model", + "type": "string" + }, + "provider": { + "default": "", + "title": "Provider", + "type": "string" + }, + "reasoning_effort": { + "default": "", + "title": "Reasoning Effort", + "type": "string" + }, + "reasoning_effort_wire": { + "default": "", + "title": "Reasoning Effort Wire", + "type": "string" + }, + "service_tier": { + "default": "", + "title": "Service Tier", + "type": "string" + }, + "fast": { + "default": false, + "title": "Fast", + "type": "boolean" + }, + "yolo": { + "default": false, + "title": "Yolo", + "type": "boolean" + }, + "approval_mode": { + "default": "manual", + "title": "Approval Mode", + "type": "string" + }, + "tools": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Tools", + "type": "object" + }, + "skills": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Skills", + "type": "object" + }, + "cwd": { + "default": "", + "title": "Cwd", + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Branch" + }, + "project": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectRef" + }, + { + "type": "null" + } + ], + "default": null + }, + "terminal_backend": { + "default": "", + "title": "Terminal Backend", + "type": "string" + }, + "personality": { + "default": "", + "title": "Personality", + "type": "string" + }, + "running": { + "default": false, + "title": "Running", + "type": "boolean" + }, + "turn_started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Started At" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "stored_session_id": { + "default": "", + "title": "Stored Session Id", + "type": "string" + }, + "desktop_contract": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Desktop Contract" + }, + "version": { + "default": "", + "title": "Version", + "type": "string" + }, + "release_date": { + "default": "", + "title": "Release Date", + "type": "string" + }, + "update_behind": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Update Behind" + }, + "update_command": { + "default": "", + "title": "Update Command", + "type": "string" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/components/schemas/Usage" + }, + { + "type": "null" + } + ], + "default": null + }, + "profile_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile Name" + }, + "mcp_servers": { + "items": { + "$ref": "#/components/schemas/McpServerStatus" + }, + "title": "Mcp Servers", + "type": "array" + }, + "system_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "System Prompt" + }, + "credential_warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Credential Warning" + }, + "lazy": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Lazy" + } + }, + "title": "SessionLiveInfo", + "type": "object" + }, + "SessionMostRecentParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "SessionMostRecentParams", + "type": "object" + }, + "SessionMostRecentResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Session Id" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + } + }, + "required": [ + "session_id" + ], + "title": "SessionMostRecentResult", + "type": "object" + }, + "SessionOwner": { + "additionalProperties": false, + "properties": { + "type": { + "const": "session", + "title": "Type", + "type": "string" + }, + "session_id": { + "minLength": 1, + "title": "Session Id", + "type": "string" + } + }, + "required": [ + "type", + "session_id" + ], + "title": "SessionOwner", + "type": "object" + }, + "SessionParams": { + "additionalProperties": false, + "description": "Any method addressed at one live session.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionParams", + "type": "object" + }, + "SessionReclaimedPayload": { + "additionalProperties": false, + "description": "``session_lifecycle._announce_session_reclaimed`` (broadcast).", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "stored_session_id": { + "title": "Stored Session Id", + "type": "string" + }, + "reason": { + "title": "Reason", + "type": "string" + } + }, + "required": [ + "session_id", + "stored_session_id", + "reason" + ], + "title": "SessionReclaimedPayload", + "type": "object" + }, + "SessionResumeParams": { + "additionalProperties": false, + "description": "``session_id`` is the STORED id (or an exact title); the reply's ``session_id`` is the runtime id.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + }, + "lazy": { + "default": false, + "title": "Lazy", + "type": "boolean" + }, + "defer_history": { + "default": false, + "title": "Defer History", + "type": "boolean" + }, + "omit_messages": { + "default": false, + "title": "Omit Messages", + "type": "boolean" + }, + "eager_build": { + "default": false, + "title": "Eager Build", + "type": "boolean" + }, + "close_on_disconnect": { + "default": false, + "title": "Close On Disconnect", + "type": "boolean" + }, + "inline_images": { + "default": true, + "title": "Inline Images", + "type": "boolean" + } + }, + "required": [ + "session_id" + ], + "title": "SessionResumeParams", + "type": "object" + }, + "SessionResumeProgressPayload": { + "additionalProperties": false, + "description": "``server._hydrate_resume_history``.", + "properties": { + "phase": { + "title": "Phase", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ResumePhaseStatus" + }, + "message_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message Count" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + } + }, + "required": [ + "phase", + "status" + ], + "title": "SessionResumeProgressPayload", + "type": "object" + }, + "SessionResumeResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "message_count": { + "title": "Message Count", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/components/schemas/TranscriptMessage" + }, + "title": "Messages", + "type": "array" + }, + "info": { + "$ref": "#/components/schemas/SessionLiveInfo" + }, + "stored_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stored Session Id" + }, + "resumed": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resumed" + }, + "session_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Key" + }, + "messages_omitted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Messages Omitted" + }, + "hydrating": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hydrating" + }, + "running": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Running" + }, + "turn_started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Started At" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "inflight": { + "anyOf": [ + { + "$ref": "#/components/schemas/InflightTurn" + }, + { + "type": "null" + } + ], + "default": null + }, + "queued": { + "anyOf": [ + { + "$ref": "#/components/schemas/QueuedPrompt" + }, + { + "type": "null" + } + ], + "default": null + }, + "pending_approval": { + "anyOf": [ + { + "$ref": "#/components/schemas/PendingApproval" + }, + { + "type": "null" + } + ], + "default": null + }, + "open_requests": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/OpenRequestEntry" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Open Requests" + }, + "pending_connection": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionRequestPayload" + }, + { + "type": "null" + } + ], + "default": null + }, + "todo_state": { + "anyOf": [ + { + "$ref": "#/components/schemas/TodoState" + }, + { + "type": "null" + } + ], + "default": null + }, + "auto_continue": { + "anyOf": [ + { + "$ref": "#/components/schemas/AutoContinue" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "session_id", + "message_count", + "messages", + "info" + ], + "title": "SessionResumeResult", + "type": "object" + }, + "SessionSaveParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionSaveParams", + "type": "object" + }, + "SessionSaveResult": { + "additionalProperties": true, + "description": "Under turn isolation the compute host's result passes through verbatim.", + "properties": { + "file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "File" + } + }, + "title": "SessionSaveResult", + "type": "object" + }, + "SessionSetHiddenParams": { + "additionalProperties": false, + "description": "``session_id`` is a live runtime id first, else a stored id / key / title.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "hidden": { + "title": "Hidden", + "type": "boolean" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id", + "hidden" + ], + "title": "SessionSetHiddenParams", + "type": "object" + }, + "SessionSetHiddenResult": { + "additionalProperties": false, + "properties": { + "hidden": { + "title": "Hidden", + "type": "boolean" + }, + "session_key": { + "title": "Session Key", + "type": "string" + } + }, + "required": [ + "hidden", + "session_key" + ], + "title": "SessionSetHiddenResult", + "type": "object" + }, + "SessionStatusParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionStatusParams", + "type": "object" + }, + "SessionStatusResult": { + "additionalProperties": false, + "properties": { + "output": { + "title": "Output", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "SessionStatusResult", + "type": "object" + }, + "SessionTitleParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + } + }, + "required": [ + "session_id" + ], + "title": "SessionTitleParams", + "type": "object" + }, + "SessionTitlePayload": { + "additionalProperties": false, + "description": "``prompt_turn._invoke_agent`` ``_on_session_title`` hook.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "session_id", + "title" + ], + "title": "SessionTitlePayload", + "type": "object" + }, + "SessionTitleResult": { + "additionalProperties": false, + "properties": { + "title": { + "title": "Title", + "type": "string" + }, + "session_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Key" + }, + "pending": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pending" + } + }, + "required": [ + "title" + ], + "title": "SessionTitleResult", + "type": "object" + }, + "SessionUndoParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "intent": { + "anyOf": [ + { + "$ref": "#/components/schemas/UndoIntent" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "session_id" + ], + "title": "SessionUndoParams", + "type": "object" + }, + "SessionUndoResult": { + "additionalProperties": false, + "properties": { + "removed": { + "title": "Removed", + "type": "integer" + } + }, + "required": [ + "removed" + ], + "title": "SessionUndoResult", + "type": "object" + }, + "SessionUsageParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionUsageParams", + "type": "object" + }, + "SessionUsagePayload": { + "additionalProperties": false, + "description": "``server._start_usage_ticker``.", + "properties": { + "usage": { + "$ref": "#/components/schemas/Usage" + } + }, + "required": [ + "usage" + ], + "title": "SessionUsagePayload", + "type": "object" + }, + "SessionUsageResult": { + "additionalProperties": true, + "properties": { + "model": { + "default": "", + "title": "Model", + "type": "string" + }, + "input": { + "default": 0, + "title": "Input", + "type": "integer" + }, + "output": { + "default": 0, + "title": "Output", + "type": "integer" + }, + "reasoning": { + "default": 0, + "title": "Reasoning", + "type": "integer" + }, + "prompt": { + "default": 0, + "title": "Prompt", + "type": "integer" + }, + "completion": { + "default": 0, + "title": "Completion", + "type": "integer" + }, + "total": { + "default": 0, + "title": "Total", + "type": "integer" + }, + "calls": { + "default": 0, + "title": "Calls", + "type": "integer" + }, + "compressions": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Compressions" + }, + "context_used": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Used" + }, + "context_max": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Max" + }, + "context_percent": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Percent" + }, + "context_source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Source" + }, + "context_estimated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Estimated" + }, + "cache_hit_pct": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cache Hit Pct" + }, + "cache_read": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cache Read" + }, + "cache_write": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cache Write" + }, + "avg_latency_s": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Avg Latency S" + }, + "avg_tps": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Avg Tps" + }, + "active_subagents": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Active Subagents" + }, + "dev_credits_spent_micros": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dev Credits Spent Micros" + }, + "cost_usd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cost Usd" + }, + "cost_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cost Status" + }, + "credits_lines": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Credits Lines" + } + }, + "title": "SessionUsageResult", + "type": "object" + }, + "SessionWorkspaceMoveParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "session_key": { + "title": "Session Key", + "type": "string" + }, + "cwd": { + "title": "Cwd", + "type": "string" + } + }, + "required": [ + "session_key", + "cwd" + ], + "title": "SessionWorkspaceMoveParams", + "type": "object" + }, + "SessionWorkspaceMoveResult": { + "additionalProperties": false, + "properties": { + "cwd": { + "title": "Cwd", + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Branch" + }, + "git_repo_root": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Git Repo Root" + } + }, + "required": [ + "cwd" + ], + "title": "SessionWorkspaceMoveResult", + "type": "object" + }, + "SetupReadyPayload": { + "additionalProperties": true, + "description": "``hermes_cli/free_tier_bootstrap.py::SetupRecord.as_payload``.", + "properties": { + "provider_configured": { + "title": "Provider Configured", + "type": "boolean" + }, + "inference_provider": { + "title": "Inference Provider", + "type": "string" + }, + "free_tier_account": { + "title": "Free Tier Account", + "type": "boolean" + }, + "free_tier_route": { + "title": "Free Tier Route", + "type": "boolean" + }, + "has_identity": { + "title": "Has Identity", + "type": "boolean" + }, + "other_providers": { + "title": "Other Providers", + "type": "boolean" + }, + "error": { + "default": "", + "title": "Error", + "type": "string" + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Code" + }, + "retryable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retryable" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "finished_at": { + "title": "Finished At", + "type": "number" + } + }, + "required": [ + "provider_configured", + "inference_provider", + "free_tier_account", + "free_tier_route", + "has_identity", + "other_providers", + "finished_at" + ], + "title": "SetupReadyPayload", + "type": "object" + }, + "SetupRuntimeCheckParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + } + }, + "title": "SetupRuntimeCheckParams", + "type": "object" + }, + "SetupRuntimeCheckResult": { + "additionalProperties": false, + "description": "``ok=False`` + ``error`` when the resolved model can't be served; ``free_tier_route`` says the\nselected route is the welcome host.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "free_tier_route": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Tier Route" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "ok" + ], + "title": "SetupRuntimeCheckResult", + "type": "object" + }, + "SetupStatusResult": { + "additionalProperties": false, + "description": "``provider_configured`` is the loose answer; the boot record's fields (``ready``,\n``free_tier_account``, ``free_tier_route``, ``other_providers``, ``inference_provider``) ride along\non the launch profile. An unknown ``profile`` answers ``ok=False`` + ``error``.", + "properties": { + "provider_configured": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider Configured" + }, + "ready": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ready" + }, + "free_tier_account": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Tier Account" + }, + "free_tier_route": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Tier Route" + }, + "other_providers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Other Providers" + }, + "inference_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inference Provider" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "ok": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ok" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Code" + }, + "retryable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retryable" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + } + }, + "title": "SetupStatusResult", + "type": "object" + }, + "SharedMetricsConsentResult": { + "additionalProperties": false, + "description": "The focused profile's ``telemetry.shared_metrics`` opt-ins. ``send`` is never true while\n``enabled`` is false; ``decided`` = either key is written in config.yaml (the shipped defaults\nare not an answer).", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "send": { + "title": "Send", + "type": "boolean" + }, + "decided": { + "title": "Decided", + "type": "boolean" + } + }, + "required": [ + "enabled", + "send", + "decided" + ], + "title": "SharedMetricsConsentResult", + "type": "object" + }, + "SharedMetricsDesktopActionDay": { + "additionalProperties": false, + "properties": { + "action": { + "title": "Action", + "type": "string" + }, + "via": { + "enum": [ + "click", + "menu", + "palette", + "shortcut" + ], + "title": "Via", + "type": "string" + }, + "count": { + "title": "Count", + "type": "integer" + } + }, + "required": [ + "action", + "via", + "count" + ], + "title": "SharedMetricsDesktopActionDay", + "type": "object" + }, + "SharedMetricsDesktopDailyParams": { + "additionalProperties": false, + "description": "One finished UTC day of Desktop use, aggregated on the client. ``day`` (YYYY-MM-DD) only latches\na resend and is never recorded; the raw counts are bucketed by the backend.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "day": { + "title": "Day", + "type": "string" + }, + "bot_count": { + "default": 0, + "title": "Bot Count", + "type": "integer" + }, + "modes": { + "items": { + "$ref": "#/components/schemas/SharedMetricsDesktopModeDay" + }, + "title": "Modes", + "type": "array" + }, + "actions": { + "items": { + "$ref": "#/components/schemas/SharedMetricsDesktopActionDay" + }, + "title": "Actions", + "type": "array" + } + }, + "required": [ + "day" + ], + "title": "SharedMetricsDesktopDailyParams", + "type": "object" + }, + "SharedMetricsDesktopDailyResult": { + "additionalProperties": false, + "properties": { + "recorded": { + "title": "Recorded", + "type": "boolean" + } + }, + "required": [ + "recorded" + ], + "title": "SharedMetricsDesktopDailyResult", + "type": "object" + }, + "SharedMetricsDesktopDislikeParams": { + "additionalProperties": false, + "description": "``signal`` quick_close|cancelled|setting_off_default|rage_click|undo|feature_disabled; ``target`` a\nclosed code-defined id for that signal (area, flow, action, undo path, feature toggle); ``setting`` a\nconfig key for setting_off_default only (the value is never sent \u2014 the backend compares it to the\ndefault).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "signal": { + "title": "Signal", + "type": "string" + }, + "target": { + "default": "", + "title": "Target", + "type": "string" + }, + "setting": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Setting" + } + }, + "required": [ + "signal" + ], + "title": "SharedMetricsDesktopDislikeParams", + "type": "object" + }, + "SharedMetricsDesktopFeatureUseParams": { + "additionalProperties": false, + "description": "``area`` is a Desktop surface id (``command_palette``, ``terminal_pane``, ``settings_`` \u2026);\nthe backend collapses anything outside its closed set to ``other``.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "area": { + "title": "Area", + "type": "string" + } + }, + "required": [ + "area" + ], + "title": "SharedMetricsDesktopFeatureUseParams", + "type": "object" + }, + "SharedMetricsDesktopFrictionParams": { + "additionalProperties": false, + "description": "``kind`` notice_dismissed|error_toast|renderer_crash|backend_disconnect|slow_frame; ``detail`` a\nclosed code-defined word for that kind (notice id, error category, crash reason, drop reason, frame\nduration bucket), never message text.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "detail": { + "title": "Detail", + "type": "string" + } + }, + "required": [ + "kind", + "detail" + ], + "title": "SharedMetricsDesktopFrictionParams", + "type": "object" + }, + "SharedMetricsDesktopModeDay": { + "additionalProperties": false, + "properties": { + "mode": { + "enum": [ + "bots", + "sessions" + ], + "title": "Mode", + "type": "string" + }, + "active_ms": { + "default": 0, + "title": "Active Ms", + "type": "number" + }, + "messages_sent": { + "default": 0, + "title": "Messages Sent", + "type": "integer" + } + }, + "required": [ + "mode" + ], + "title": "SharedMetricsDesktopModeDay", + "type": "object" + }, + "SharedMetricsDesktopOnboardingParams": { + "additionalProperties": false, + "description": "``step`` a Desktop first-run step id; ``event`` reached|completed|abandoned.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "step": { + "title": "Step", + "type": "string" + }, + "event": { + "title": "Event", + "type": "string" + } + }, + "required": [ + "step", + "event" + ], + "title": "SharedMetricsDesktopOnboardingParams", + "type": "object" + }, + "SharedMetricsSetParams": { + "additionalProperties": false, + "description": "``send`` is ignored unless ``enabled``; ``first_run`` marks the Desktop first-run answer.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "send": { + "default": false, + "title": "Send", + "type": "boolean" + }, + "first_run": { + "default": false, + "title": "First Run", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "title": "SharedMetricsSetParams", + "type": "object" + }, + "SharedMetricsSlashCommandParams": { + "additionalProperties": false, + "description": "``command`` is the raw typed name (no leading ``/``, no args); the backend canonicalizes it\nagainst the published registry. ``session_id`` scopes the count to that session's profile.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "command": { + "title": "Command", + "type": "string" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "required": [ + "command" + ], + "title": "SharedMetricsSlashCommandParams", + "type": "object" + }, + "SharedMetricsSlashCommandResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "SharedMetricsSlashCommandResult", + "type": "object" + }, + "SharedMetricsStartupLatencyParams": { + "additionalProperties": false, + "description": "``elapsed_ms`` = the client's own launch (TUI process start / Desktop app start) to ready\n(TUI gateway ready / Desktop backend attached), measured once per launch by the client. The\nclient names its surface because a Desktop may attach to a URL/cloud backend where\n``HERMES_DESKTOP`` is unset; without it the backend falls back to its own client detection.\n``launch_id`` is an opaque per-launch token the backend latches on (never recorded), so a\nreconnect re-sending the same launch counts once while a new launch counts again.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "elapsed_ms": { + "title": "Elapsed Ms", + "type": "number" + }, + "surface": { + "anyOf": [ + { + "enum": [ + "desktop_attach", + "tui" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Surface" + }, + "launch_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Launch Id" + } + }, + "required": [ + "elapsed_ms" + ], + "title": "SharedMetricsStartupLatencyParams", + "type": "object" + }, + "SharedMetricsStartupLatencyResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "SharedMetricsStartupLatencyResult", + "type": "object" + }, + "SharedMetricsUpdateRunParams": { + "additionalProperties": false, + "description": "One Desktop PACKAGED self-update (electron-updater / App Installer / Store). Source-checkout\nhand-offs run ``hermes update`` and are counted from its receipt, never here. Raw words; the\nbackend buckets them: ``outcome`` success|failed|noop|refused, ``failed_stage``\ndownload|verify|apply|restart, ``mechanism`` the updater strategy kind, ``duration_ms`` wall\ntime, ``from_commit_date`` the updated-from build's commit time (epoch seconds) when known.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "outcome": { + "title": "Outcome", + "type": "string" + }, + "failed_stage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Failed Stage" + }, + "mechanism": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mechanism" + }, + "duration_ms": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duration Ms" + }, + "from_commit_date": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "From Commit Date" + } + }, + "required": [ + "outcome" + ], + "title": "SharedMetricsUpdateRunParams", + "type": "object" + }, + "SharedMetricsUpdateRunResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "SharedMetricsUpdateRunResult", + "type": "object" + }, + "ShellExecParams": { + "additionalProperties": false, + "properties": { + "command": { + "title": "Command", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "command" + ], + "title": "ShellExecParams", + "type": "object" + }, + "ShellExecResult": { + "additionalProperties": false, + "properties": { + "stdout": { + "title": "Stdout", + "type": "string" + }, + "stderr": { + "title": "Stderr", + "type": "string" + }, + "code": { + "title": "Code", + "type": "integer" + } + }, + "required": [ + "stdout", + "stderr", + "code" + ], + "title": "ShellExecResult", + "type": "object" + }, + "SideAgentCompletePayload": { + "additionalProperties": false, + "description": "``methods_prompt._spawn_side_agent``: ``{task_id, **extra, text}``; btw adds ``question``.", + "properties": { + "task_id": { + "title": "Task Id", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + }, + "question": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Question" + } + }, + "required": [ + "task_id", + "text" + ], + "title": "SideAgentCompletePayload", + "type": "object" + }, + "SideAgentParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "session_id", + "text" + ], + "title": "SideAgentParams", + "type": "object" + }, + "SkillBrowseItem": { + "additionalProperties": true, + "description": "``hermes_cli.skills_hub.browse_skills`` row.", + "properties": { + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "source": { + "default": "", + "title": "Source", + "type": "string" + }, + "trust": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Trust" + }, + "identifier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identifier" + } + }, + "title": "SkillBrowseItem", + "type": "object" + }, + "SkillCatalogEntry": { + "additionalProperties": false, + "properties": { + "usage": { + "default": 0, + "title": "Usage", + "type": "integer" + }, + "origin": { + "default": "local", + "title": "Origin", + "type": "string" + } + }, + "title": "SkillCatalogEntry", + "type": "object" + }, + "SkillCommandRef": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "SkillCommandRef", + "type": "object" + }, + "SkillHubHit": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + } + }, + "required": [ + "name", + "description" + ], + "title": "SkillHubHit", + "type": "object" + }, + "SkillInspectInfo": { + "additionalProperties": true, + "description": "``hermes_cli.skills_hub.inspect_skill``; ``{}`` when the identifier resolves nowhere.", + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + }, + "identifier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identifier" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tags" + }, + "skill_md_preview": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skill Md Preview" + } + }, + "title": "SkillInspectInfo", + "type": "object" + }, + "SkillsAction": { + "enum": [ + "list", + "search", + "install", + "browse", + "inspect" + ], + "title": "SkillsAction", + "type": "string" + }, + "SkillsManageParams": { + "additionalProperties": false, + "description": "``query`` is the search text / hub identifier / browse page (digits); ``page`` / ``page_size``\napply to ``browse``.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "action": { + "$ref": "#/components/schemas/SkillsAction", + "default": "list" + }, + "query": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Query" + }, + "page": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page" + }, + "page_size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page Size" + } + }, + "title": "SkillsManageParams", + "type": "object" + }, + "SkillsManageResult": { + "additionalProperties": false, + "description": "Shape follows the action: ``list`` \u2192 ``skills`` (category \u2192 names); ``search`` \u2192 ``results``;\n``install`` \u2192 ``installed`` + ``name``; ``browse`` \u2192 ``items`` + paging; ``inspect`` \u2192 ``info``.", + "properties": { + "skills": { + "anyOf": [ + { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skills" + }, + "results": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SkillHubHit" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Results" + }, + "installed": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Installed" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "items": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SkillBrowseItem" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Items" + }, + "page": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page" + }, + "total_pages": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Total Pages" + }, + "total": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Total" + }, + "info": { + "anyOf": [ + { + "$ref": "#/components/schemas/SkillInspectInfo" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "SkillsManageResult", + "type": "object" + }, + "SkillsReloadDiff": { + "additionalProperties": true, + "description": "``agent.skill_commands.reload_skills``.", + "properties": { + "added": { + "items": { + "$ref": "#/components/schemas/SkillCommandRef" + }, + "title": "Added", + "type": "array" + }, + "removed": { + "items": { + "$ref": "#/components/schemas/SkillCommandRef" + }, + "title": "Removed", + "type": "array" + }, + "unchanged": { + "items": { + "type": "string" + }, + "title": "Unchanged", + "type": "array" + }, + "total": { + "default": 0, + "title": "Total", + "type": "integer" + }, + "commands": { + "default": 0, + "title": "Commands", + "type": "integer" + } + }, + "title": "SkillsReloadDiff", + "type": "object" + }, + "SkillsReloadParams": { + "additionalProperties": false, + "description": "``session_id`` binds the rescan to that session's profile and workspace (project skills);\n``profile`` scopes a session-less rescan.", + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "SkillsReloadParams", + "type": "object" + }, + "SkillsReloadResult": { + "additionalProperties": false, + "properties": { + "output": { + "title": "Output", + "type": "string" + }, + "result": { + "$ref": "#/components/schemas/SkillsReloadDiff" + } + }, + "required": [ + "output", + "result" + ], + "title": "SkillsReloadResult", + "type": "object" + }, + "SkinPayload": { + "additionalProperties": true, + "description": "``tui_gateway/change_watcher.py::resolve_skin`` \u2014 the resolved active skin (``HermesSkin``).\n``{}`` when the skin engine failed to load. Colour maps are token \u2192 colour string.", + "properties": { + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "colors": { + "additionalProperties": { + "type": "string" + }, + "title": "Colors", + "type": "object" + }, + "light_colors": { + "additionalProperties": { + "type": "string" + }, + "title": "Light Colors", + "type": "object" + }, + "dark_colors": { + "additionalProperties": { + "type": "string" + }, + "title": "Dark Colors", + "type": "object" + }, + "branding": { + "additionalProperties": { + "type": "string" + }, + "title": "Branding", + "type": "object" + }, + "banner_logo": { + "default": "", + "title": "Banner Logo", + "type": "string" + }, + "banner_hero": { + "default": "", + "title": "Banner Hero", + "type": "string" + }, + "tool_prefix": { + "default": "", + "title": "Tool Prefix", + "type": "string" + }, + "help_header": { + "default": "", + "title": "Help Header", + "type": "string" + } + }, + "title": "SkinPayload", + "type": "object" + }, + "SlashExecParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "command": { + "title": "Command", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id", + "command" + ], + "title": "SlashExecParams", + "type": "object" + }, + "SlashExecResult": { + "additionalProperties": false, + "description": "Plain worker/plugin text in ``output`` (+ ``warning``), or \u2014 when the command was rerouted to\n``command.dispatch`` \u2014 that method's directive fields with ``type`` set.", + "properties": { + "output": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Output" + }, + "warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Warning" + }, + "type": { + "anyOf": [ + { + "$ref": "#/components/schemas/DispatchType" + }, + { + "type": "null" + } + ], + "default": null + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "notice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Notice" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + } + }, + "title": "SlashExecResult", + "type": "object" + }, + "SpawnTreeEntry": { + "additionalProperties": true, + "description": "Index row (``server._append_spawn_tree_index``) or a legacy file scan.", + "properties": { + "path": { + "title": "Path", + "type": "string" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "finished_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Finished At" + }, + "label": { + "default": "", + "title": "Label", + "type": "string" + }, + "count": { + "default": 0, + "title": "Count", + "type": "integer" + } + }, + "required": [ + "path" + ], + "title": "SpawnTreeEntry", + "type": "object" + }, + "SpawnTreeListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "cross_session": { + "default": false, + "title": "Cross Session", + "type": "boolean" + }, + "limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Limit" + } + }, + "title": "SpawnTreeListParams", + "type": "object" + }, + "SpawnTreeListResult": { + "additionalProperties": false, + "properties": { + "entries": { + "items": { + "$ref": "#/components/schemas/SpawnTreeEntry" + }, + "title": "Entries", + "type": "array" + } + }, + "required": [ + "entries" + ], + "title": "SpawnTreeListResult", + "type": "object" + }, + "SpawnTreeLoadParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "path": { + "title": "Path", + "type": "string" + } + }, + "required": [ + "path" + ], + "title": "SpawnTreeLoadParams", + "type": "object" + }, + "SpawnTreeLoadResult": { + "additionalProperties": true, + "description": "The snapshot file as written by ``spawn_tree.save`` (open: the file is the contract).", + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "finished_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Finished At" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + }, + "subagents": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Subagents", + "type": "array" + } + }, + "title": "SpawnTreeLoadResult", + "type": "object" + }, + "SpawnTreeSaveParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "subagents": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Subagents", + "type": "array" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "finished_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Finished At" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + } + }, + "required": [ + "subagents" + ], + "title": "SpawnTreeSaveParams", + "type": "object" + }, + "SpawnTreeSaveResult": { + "additionalProperties": false, + "properties": { + "path": { + "title": "Path", + "type": "string" + }, + "session_id": { + "title": "Session Id", + "type": "string" + } + }, + "required": [ + "path", + "session_id" + ], + "title": "SpawnTreeSaveResult", + "type": "object" + }, + "StatusUpdatePayload": { + "additionalProperties": false, + "description": "``server._status_update`` and the direct emitters (goal / loop / heartbeat / process).", + "properties": { + "kind": { + "title": "Kind", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "kind", + "text" + ], + "title": "StatusUpdatePayload", + "type": "object" + }, + "SteerStatus": { + "enum": [ + "queued", + "rejected" + ], + "title": "SteerStatus", + "type": "string" + }, + "StreamDeltaPayload": { + "additionalProperties": false, + "description": "``prompt_turn._invoke_agent._stream`` (message.delta: ``text`` + optional ``rendered``),\n``agent_callbacks._agent_cbs`` (reasoning.delta / thinking.delta), ``tool_progress._progress_reasoning``\n(reasoning.available). ``verbose`` rides only when the session's verbose reasoning mode is on.", + "properties": { + "text": { + "title": "Text", + "type": "string" + }, + "rendered": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rendered" + }, + "verbose": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Verbose" + } + }, + "required": [ + "text" + ], + "title": "StreamDeltaPayload", + "type": "object" + }, + "SubagentEventPayload": { + "additionalProperties": false, + "description": "``tool_progress._progress_subagent`` \u2014 every ``subagent.*`` frame; identity fields are optional\nbecause older emitters omit them and the TUI spawn tree falls back to flat rendering.", + "properties": { + "goal": { + "title": "Goal", + "type": "string" + }, + "task_count": { + "title": "Task Count", + "type": "integer" + }, + "task_index": { + "title": "Task Index", + "type": "integer" + }, + "subagent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subagent Id" + }, + "parent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Id" + }, + "child_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Child Session Id" + }, + "delegation_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Delegation Id" + }, + "depth": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Depth" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "tool_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Count" + }, + "toolsets": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Toolsets" + }, + "input_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Input Tokens" + }, + "output_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Output Tokens" + }, + "reasoning_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reasoning Tokens" + }, + "api_calls": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Calls" + }, + "files_read": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Files Read" + }, + "files_written": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Files Written" + }, + "output_tail": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SubagentOutputTailEntry" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Output Tail" + }, + "tool_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Name" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubagentStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Summary" + }, + "duration_seconds": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duration Seconds" + }, + "tool_preview": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Preview" + } + }, + "required": [ + "goal", + "task_count", + "task_index" + ], + "title": "SubagentEventPayload", + "type": "object" + }, + "SubagentIdParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "subagent_id": { + "title": "Subagent Id", + "type": "string" + } + }, + "required": [ + "session_id", + "subagent_id" + ], + "title": "SubagentIdParams", + "type": "object" + }, + "SubagentInterruptResult": { + "additionalProperties": false, + "properties": { + "found": { + "title": "Found", + "type": "boolean" + }, + "subagent_id": { + "title": "Subagent Id", + "type": "string" + } + }, + "required": [ + "found", + "subagent_id" + ], + "title": "SubagentInterruptResult", + "type": "object" + }, + "SubagentListResult": { + "additionalProperties": false, + "description": "``delegations``: recently failed async delegation tasks for the session (durable store), newest first.", + "properties": { + "subagents": { + "items": { + "$ref": "#/components/schemas/SubagentSnapshot" + }, + "title": "Subagents", + "type": "array" + }, + "delegations": { + "items": { + "$ref": "#/components/schemas/FailedDelegation" + }, + "title": "Delegations", + "type": "array" + } + }, + "title": "SubagentListResult", + "type": "object" + }, + "SubagentOutputTailEntry": { + "additionalProperties": false, + "description": "``tools/delegate_tool_results.py::_extract_output_tail`` row.", + "properties": { + "tool": { + "title": "Tool", + "type": "string" + }, + "preview": { + "title": "Preview", + "type": "string" + }, + "is_error": { + "title": "Is Error", + "type": "boolean" + } + }, + "required": [ + "tool", + "preview", + "is_error" + ], + "title": "SubagentOutputTailEntry", + "type": "object" + }, + "SubagentSnapshot": { + "additionalProperties": false, + "description": "``methods_subagents._SUBAGENT_SNAPSHOT_FIELDS`` projection of one live child record.", + "properties": { + "subagent_id": { + "title": "Subagent Id", + "type": "string" + }, + "parent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Id" + }, + "depth": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Depth" + }, + "goal": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Goal" + }, + "delegation_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Delegation Id" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubagentStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "tool_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Count" + }, + "last_tool": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Tool" + }, + "accepting_steer": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Accepting Steer" + } + }, + "required": [ + "subagent_id" + ], + "title": "SubagentSnapshot", + "type": "object" + }, + "SubagentStatus": { + "description": "Lifecycle of one delegated child (``tools/delegate_tool_child_run.py``); ``failed`` /\n``error`` / ``timeout`` / ``interrupted`` / ``completed`` are terminal.", + "enum": [ + "queued", + "running", + "completed", + "failed", + "error", + "timeout", + "interrupted" + ], + "title": "SubagentStatus", + "type": "string" + }, + "SubagentSteerParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "subagent_id": { + "title": "Subagent Id", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "session_id", + "subagent_id", + "text" + ], + "title": "SubagentSteerParams", + "type": "object" + }, + "SubagentSteerResult": { + "additionalProperties": false, + "description": "``queued`` is not ``delivered``: a child past its final tool batch surfaces ``missed_steer``.", + "properties": { + "status": { + "$ref": "#/components/schemas/SteerStatus" + }, + "subagent_id": { + "title": "Subagent Id", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "status", + "subagent_id", + "text" + ], + "title": "SubagentSteerResult", + "type": "object" + }, + "SubagentTailResult": { + "additionalProperties": false, + "description": "``available`` is false while the child has no live transcript yet (or it was cleaned up).", + "properties": { + "subagent_id": { + "title": "Subagent Id", + "type": "string" + }, + "available": { + "default": false, + "title": "Available", + "type": "boolean" + }, + "text": { + "default": "", + "title": "Text", + "type": "string" + }, + "truncated": { + "default": false, + "title": "Truncated", + "type": "boolean" + } + }, + "required": [ + "subagent_id" + ], + "title": "SubagentTailResult", + "type": "object" + }, + "SubscriptionChangeEffect": { + "enum": [ + "charge_now", + "scheduled", + "no_op", + "blocked" + ], + "title": "SubscriptionChangeEffect", + "type": "string" + }, + "SubscriptionChangeParams": { + "additionalProperties": false, + "description": "Either a target tier (downgrade / same-price change) or ``cancel`` (period-end cancellation).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "subscription_type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subscription Type Id" + }, + "cancel": { + "default": false, + "title": "Cancel", + "type": "boolean" + } + }, + "title": "SubscriptionChangeParams", + "type": "object" + }, + "SubscriptionContext": { + "enum": [ + "personal", + "team" + ], + "title": "SubscriptionContext", + "type": "string" + }, + "SubscriptionPreviewParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "subscription_type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subscription Type Id" + } + }, + "title": "SubscriptionPreviewParams", + "type": "object" + }, + "SubscriptionPreviewResult": { + "additionalProperties": false, + "description": "``_serialize_subscription_preview`` on success; ``effect`` drives the confirm copy.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery" + }, + "effect": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubscriptionChangeEffect" + }, + { + "type": "null" + } + ], + "default": null + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "current_tier_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Current Tier Id" + }, + "current_tier_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Current Tier Name" + }, + "target_tier_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target Tier Id" + }, + "target_tier_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target Tier Name" + }, + "monthly_credits_delta": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Monthly Credits Delta" + }, + "amount_due_now_cents": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Amount Due Now Cents" + }, + "effective_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Effective At" + } + }, + "required": [ + "ok" + ], + "title": "SubscriptionPreviewResult", + "type": "object" + }, + "SubscriptionStateResult": { + "additionalProperties": false, + "description": "``_serialize_subscription_state``; the view's fallback emits only ``ok / logged_in / error``.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "logged_in": { + "title": "Logged In", + "type": "boolean" + }, + "is_admin": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is Admin" + }, + "can_change_plan": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Can Change Plan" + }, + "org_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Org Name" + }, + "org_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Org Id" + }, + "role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Role" + }, + "context": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubscriptionContext" + }, + { + "type": "null" + } + ], + "default": null + }, + "current": { + "anyOf": [ + { + "$ref": "#/components/schemas/CurrentSubscription" + }, + { + "type": "null" + } + ], + "default": null + }, + "tiers": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SubscriptionTierOption" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tiers" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/components/schemas/UsageModel" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "ok", + "logged_in" + ], + "title": "SubscriptionStateResult", + "type": "object" + }, + "SubscriptionTierOption": { + "additionalProperties": false, + "properties": { + "tier_id": { + "title": "Tier Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "tier_order": { + "title": "Tier Order", + "type": "integer" + }, + "dollars_per_month_display": { + "title": "Dollars Per Month Display", + "type": "string" + }, + "monthly_credits": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Monthly Credits" + }, + "is_current": { + "title": "Is Current", + "type": "boolean" + }, + "is_enabled": { + "title": "Is Enabled", + "type": "boolean" + } + }, + "required": [ + "tier_id", + "name", + "tier_order", + "dollars_per_month_display", + "is_current", + "is_enabled" + ], + "title": "SubscriptionTierOption", + "type": "object" + }, + "SubscriptionUpgradeParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "subscription_type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subscription Type Id" + }, + "idempotency_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Idempotency Key" + } + }, + "title": "SubscriptionUpgradeParams", + "type": "object" + }, + "SubscriptionUpgradeResult": { + "additionalProperties": false, + "description": "The money route: ``status`` separates a completed upgrade from an SCA / decline that must\nfinish in the portal at ``recovery_url``; ``idempotency_key`` is echoed (also on error) so a\nretry reuses it.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "target_tier_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target Tier Name" + }, + "recovery_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery Url" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "idempotency_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Idempotency Key" + } + }, + "required": [ + "ok" + ], + "title": "SubscriptionUpgradeResult", + "type": "object" + }, + "SudoRequestParams": { + "additionalProperties": false, + "description": "Original command, redacted server-side before any password-injection rewrite.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "command": { + "default": "", + "title": "Command", + "type": "string" + } + }, + "required": [ + "session_id" + ], + "title": "SudoRequestParams", + "type": "object" + }, + "SystemBatteryParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "SystemBatteryParams", + "type": "object" + }, + "SystemBatteryResult": { + "additionalProperties": false, + "properties": { + "available": { + "title": "Available", + "type": "boolean" + }, + "percent": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Percent" + }, + "plugged": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Plugged" + }, + "category": { + "$ref": "#/components/schemas/BatteryCategory", + "default": "dim" + } + }, + "required": [ + "available" + ], + "title": "SystemBatteryResult", + "type": "object" + }, + "TaskIdResult": { + "additionalProperties": false, + "description": "The side agent runs detached; its answer lands on the parent session as an event carrying\nthis ``task_id``.", + "properties": { + "task_id": { + "title": "Task Id", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "title": "TaskIdResult", + "type": "object" + }, + "TerminalClosePayload": { + "additionalProperties": false, + "properties": { + "process_id": { + "title": "Process Id", + "type": "string" + } + }, + "required": [ + "process_id" + ], + "title": "TerminalClosePayload", + "type": "object" + }, + "TerminalOutputPayload": { + "additionalProperties": false, + "description": "``session_notifications`` process_registry.on_output.", + "properties": { + "process_id": { + "title": "Process Id", + "type": "string" + }, + "chunk": { + "title": "Chunk", + "type": "string" + } + }, + "required": [ + "process_id", + "chunk" + ], + "title": "TerminalOutputPayload", + "type": "object" + }, + "TerminalResizeParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + } + }, + "required": [ + "session_id" + ], + "title": "TerminalResizeParams", + "type": "object" + }, + "TerminalResizeResult": { + "additionalProperties": false, + "properties": { + "cols": { + "title": "Cols", + "type": "integer" + } + }, + "required": [ + "cols" + ], + "title": "TerminalResizeResult", + "type": "object" + }, + "TipShowPayload": { + "additionalProperties": false, + "description": "``tools/tip_tool.py``.", + "properties": { + "selector": { + "title": "Selector", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "side": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Side" + } + }, + "required": [ + "selector", + "text" + ], + "title": "TipShowPayload", + "type": "object" + }, + "TodoState": { + "additionalProperties": false, + "description": "``tool_progress._normalize_todo_state``: the authoritative todo snapshot.", + "properties": { + "todos": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Todos", + "type": "array" + }, + "revision": { + "title": "Revision", + "type": "integer" + } + }, + "required": [ + "todos", + "revision" + ], + "title": "TodoState", + "type": "object" + }, + "TodoUpdatedPayload": { + "additionalProperties": false, + "description": "``tool_progress._normalize_todo_state`` \u2014 full task snapshot.", + "properties": { + "todos": { + "items": {}, + "title": "Todos", + "type": "array" + }, + "revision": { + "title": "Revision", + "type": "integer" + } + }, + "required": [ + "todos", + "revision" + ], + "title": "TodoUpdatedPayload", + "type": "object" + }, + "ToolCompletePayload": { + "additionalProperties": false, + "description": "``tool_progress._on_tool_complete``; ``todos``/``revision`` merged in for the todo tools.", + "properties": { + "tool_id": { + "title": "Tool Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "args": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Args" + }, + "duration_s": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duration S" + }, + "result": { + "default": null, + "title": "Result" + }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Summary" + }, + "result_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Result Text" + }, + "inline_diff": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inline Diff" + }, + "todos": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Todos" + }, + "revision": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Revision" + }, + "labels": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ToolLabel" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Labels" + } + }, + "required": [ + "tool_id", + "name" + ], + "title": "ToolCompletePayload", + "type": "object" + }, + "ToolGeneratingPayload": { + "additionalProperties": false, + "description": "``agent_callbacks`` tool_gen_callback.", + "properties": { + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ToolGeneratingPayload", + "type": "object" + }, + "ToolLabel": { + "additionalProperties": false, + "description": "``tools.tool_labels.ToolLabel`` \u2014 what one call executed through the tool_search bridge is,\nin words. Clients render ``text`` (or ``app``/``action`` in their own columns) and never parse\nthe tool name themselves.", + "properties": { + "kind": { + "$ref": "#/components/schemas/ToolLabelKind" + }, + "app": { + "title": "App", + "type": "string" + }, + "action": { + "title": "Action", + "type": "string" + }, + "emoji": { + "title": "Emoji", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "preview": { + "default": "", + "title": "Preview", + "type": "string" + } + }, + "required": [ + "kind", + "app", + "action", + "emoji", + "text", + "name" + ], + "title": "ToolLabel", + "type": "object" + }, + "ToolLabelKind": { + "description": "Which surface one inner call of a bridged ``tool_call`` runs on.", + "enum": [ + "connector", + "mcp", + "tool" + ], + "title": "ToolLabelKind", + "type": "string" + }, + "ToolOutputRiskPayload": { + "additionalProperties": false, + "description": "``tool_progress._progress_output_risk``.", + "properties": { + "tool_id": { + "title": "Tool Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "risk": { + "title": "Risk", + "type": "string" + }, + "findings": { + "items": { + "type": "string" + }, + "title": "Findings", + "type": "array" + }, + "redacted": { + "title": "Redacted", + "type": "boolean" + } + }, + "required": [ + "tool_id", + "name", + "risk", + "findings", + "redacted" + ], + "title": "ToolOutputRiskPayload", + "type": "object" + }, + "ToolShowRow": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + } + }, + "required": [ + "name", + "description" + ], + "title": "ToolShowRow", + "type": "object" + }, + "ToolShowSection": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/components/schemas/ToolShowRow" + }, + "title": "Tools", + "type": "array" + } + }, + "required": [ + "name", + "tools" + ], + "title": "ToolShowSection", + "type": "object" + }, + "ToolStartPayload": { + "additionalProperties": false, + "description": "``tool_progress._on_tool_start`` (+ ``agent_callbacks._mirror_subagent_to_child`` rows with\n``preview`` and empty ``args``). ``todos``/``revision`` are NOT set by the emitter; kept optional\nbecause tool.start rows may pass through connector redaction unchanged.", + "properties": { + "tool_id": { + "title": "Tool Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "context": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context" + }, + "args": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Args" + }, + "args_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Args Text" + }, + "preview": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Preview" + }, + "labels": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ToolLabel" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Labels" + } + }, + "required": [ + "tool_id", + "name" + ], + "title": "ToolStartPayload", + "type": "object" + }, + "ToolsAction": { + "enum": [ + "enable", + "disable" + ], + "title": "ToolsAction", + "type": "string" + }, + "ToolsChange": { + "additionalProperties": false, + "properties": { + "type": { + "const": "tools", + "title": "Type", + "type": "string" + }, + "connector": { + "title": "Connector", + "type": "string" + }, + "disabled_tools": { + "items": { + "type": "string" + }, + "maxItems": 2000, + "title": "Disabled Tools", + "type": "array" + } + }, + "required": [ + "type", + "connector", + "disabled_tools" + ], + "title": "ToolsChange", + "type": "object" + }, + "ToolsConfigureParams": { + "additionalProperties": false, + "description": "``names`` are toolset keys or ``server:tool`` MCP targets; with ``session_id`` the live session's\nprofile is authoritative and its agent is rebuilt.", + "properties": { + "action": { + "$ref": "#/components/schemas/ToolsAction" + }, + "names": { + "items": { + "type": "string" + }, + "title": "Names", + "type": "array" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "action", + "names" + ], + "title": "ToolsConfigureParams", + "type": "object" + }, + "ToolsConfigureResult": { + "additionalProperties": false, + "properties": { + "changed": { + "items": { + "type": "string" + }, + "title": "Changed", + "type": "array" + }, + "enabled_toolsets": { + "items": { + "type": "string" + }, + "title": "Enabled Toolsets", + "type": "array" + }, + "info": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionLiveInfo" + }, + { + "type": "null" + } + ], + "default": null + }, + "missing_servers": { + "items": { + "type": "string" + }, + "title": "Missing Servers", + "type": "array" + }, + "reset": { + "title": "Reset", + "type": "boolean" + }, + "unknown": { + "items": { + "type": "string" + }, + "title": "Unknown", + "type": "array" + } + }, + "required": [ + "changed", + "enabled_toolsets", + "missing_servers", + "reset", + "unknown" + ], + "title": "ToolsConfigureResult", + "type": "object" + }, + "ToolsShowResult": { + "additionalProperties": false, + "properties": { + "sections": { + "items": { + "$ref": "#/components/schemas/ToolShowSection" + }, + "title": "Sections", + "type": "array" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "sections", + "total" + ], + "title": "ToolsShowResult", + "type": "object" + }, + "ToolsetEntry": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "label": { + "default": "", + "title": "Label", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "tool_count": { + "default": 0, + "title": "Tool Count", + "type": "integer" + } + }, + "required": [ + "name" + ], + "title": "ToolsetEntry", + "type": "object" + }, + "ToolsetRow": { + "additionalProperties": false, + "description": "One row of ``methods_tools._toolset_rows``; ``tools`` only when the caller asked for them\n(``tools.list``).", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "tool_count": { + "title": "Tool Count", + "type": "integer" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "tools": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tools" + } + }, + "required": [ + "name", + "description", + "tool_count", + "enabled" + ], + "title": "ToolsetRow", + "type": "object" + }, + "ToolsetsListResult": { + "additionalProperties": false, + "properties": { + "toolsets": { + "items": { + "$ref": "#/components/schemas/ToolsetRow" + }, + "title": "Toolsets", + "type": "array" + } + }, + "required": [ + "toolsets" + ], + "title": "ToolsetsListResult", + "type": "object" + }, + "TourRequestParams": { + "additionalProperties": false, + "description": "``tools/tour_tool.py`` field set.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "action": { + "title": "Action", + "type": "string" + }, + "surface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Surface" + }, + "selector": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Selector" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "side": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Side" + }, + "steps": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/TourStep" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Steps" + }, + "step_index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Step Index" + } + }, + "required": [ + "session_id", + "action" + ], + "title": "TourRequestParams", + "type": "object" + }, + "TourStep": { + "additionalProperties": true, + "properties": { + "selector": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Selector" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "side": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Side" + } + }, + "title": "TourStep", + "type": "object" + }, + "TranscriptMessage": { + "additionalProperties": true, + "description": "One transcript row as the gateway PROJECTS it for renderers (``session_history._project_history``):\n``text``, display-only ``timestamp`` / ``display_kind`` / ``display_metadata``, the durable ``row_id``\nrewind targets, and for tool rows raw ``content``, ``tool_call_id``, ``name``, ``context`` and ``args``.\nAssistant detail sidecars (``reasoning``, \u2026) ride as extra keys.", + "properties": { + "role": { + "title": "Role", + "type": "string" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "content": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Content" + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Call Id" + }, + "timestamp": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Timestamp" + }, + "row_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Row Id" + }, + "display_kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Kind" + }, + "display_metadata": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Display Metadata" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "context": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context" + }, + "args": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Args" + }, + "labels": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ToolLabel" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Labels" + }, + "reasoning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reasoning" + } + }, + "required": [ + "role" + ], + "title": "TranscriptMessage", + "type": "object" + }, + "TurnStatus": { + "description": "``prompt_turn._result_status``.", + "enum": [ + "complete", + "error", + "interrupted" + ], + "title": "TurnStatus", + "type": "string" + }, + "UiMetaConflict": { + "additionalProperties": false, + "properties": { + "expected": { + "default": null, + "title": "Expected" + }, + "actual": { + "default": 0, + "title": "Actual", + "type": "integer" + } + }, + "title": "UiMetaConflict", + "type": "object" + }, + "UndoIntent": { + "enum": [ + "retry", + "undo" + ], + "title": "UndoIntent", + "type": "string" + }, + "Usage": { + "additionalProperties": true, + "description": "``tui_gateway/server.py::_get_usage`` + ``agent/context_breakdown.py::context_usage_fields``.", + "properties": { + "model": { + "default": "", + "title": "Model", + "type": "string" + }, + "input": { + "default": 0, + "title": "Input", + "type": "integer" + }, + "output": { + "default": 0, + "title": "Output", + "type": "integer" + }, + "reasoning": { + "default": 0, + "title": "Reasoning", + "type": "integer" + }, + "prompt": { + "default": 0, + "title": "Prompt", + "type": "integer" + }, + "completion": { + "default": 0, + "title": "Completion", + "type": "integer" + }, + "total": { + "default": 0, + "title": "Total", + "type": "integer" + }, + "calls": { + "default": 0, + "title": "Calls", + "type": "integer" + }, + "compressions": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Compressions" + }, + "context_used": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Used" + }, + "context_max": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Max" + }, + "context_percent": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Percent" + }, + "context_source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Source" + }, + "context_estimated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Estimated" + }, + "cache_hit_pct": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cache Hit Pct" + }, + "cache_read": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cache Read" + }, + "cache_write": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cache Write" + }, + "avg_latency_s": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Avg Latency S" + }, + "avg_tps": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Avg Tps" + }, + "active_subagents": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Active Subagents" + }, + "dev_credits_spent_micros": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dev Credits Spent Micros" + }, + "cost_usd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cost Usd" + }, + "cost_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cost Status" + } + }, + "title": "Usage", + "type": "object" + }, + "UsageBar": { + "additionalProperties": false, + "description": "``_serialize_usage_bar``: one bar, magnitudes pre-formatted; ``pct_used`` only for ``plan``.", + "properties": { + "kind": { + "$ref": "#/components/schemas/UsageBarKind" + }, + "remaining_display": { + "title": "Remaining Display", + "type": "string" + }, + "total_display": { + "title": "Total Display", + "type": "string" + }, + "spent_display": { + "title": "Spent Display", + "type": "string" + }, + "pct_used": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pct Used" + }, + "fill_fraction": { + "title": "Fill Fraction", + "type": "number" + } + }, + "required": [ + "kind", + "remaining_display", + "total_display", + "spent_display", + "fill_fraction" + ], + "title": "UsageBar", + "type": "object" + }, + "UsageBarKind": { + "enum": [ + "plan", + "topup" + ], + "title": "UsageBarKind", + "type": "string" + }, + "UsageModel": { + "additionalProperties": false, + "description": "``_serialize_usage_model`` \u2014 also embedded as ``usage`` in the billing / subscription states,\nwhere the fail-open form is a bare ``{available: false}`` (no ``ok``).", + "properties": { + "ok": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ok" + }, + "available": { + "title": "Available", + "type": "boolean" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "plan_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Plan Name" + }, + "renews_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Renews At" + }, + "renews_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Renews Display" + }, + "subscription_remaining_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subscription Remaining Display" + }, + "topup_remaining_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Topup Remaining Display" + }, + "total_spendable_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Total Spendable Display" + }, + "has_topup": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Has Topup" + }, + "plan_bar": { + "anyOf": [ + { + "$ref": "#/components/schemas/UsageBar" + }, + { + "type": "null" + } + ], + "default": null + }, + "topup_bar": { + "anyOf": [ + { + "$ref": "#/components/schemas/UsageBar" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "available" + ], + "title": "UsageModel", + "type": "object" + }, + "ValueResult": { + "additionalProperties": false, + "description": "The answer to any one-string prompt (sudo, secret, vault prompts, desktop bridges):\n``''`` means skipped / declined.", + "properties": { + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "value" + ], + "title": "ValueResult", + "type": "object" + }, + "VaultAddParams": { + "additionalProperties": false, + "description": "``secret`` goes straight into the encrypted store; the result carries only the new id.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "kind": { + "anyOf": [ + { + "$ref": "#/components/schemas/VaultKind" + }, + { + "type": "null" + } + ], + "default": null + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + }, + "origin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Origin" + }, + "secret": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Secret" + } + }, + "title": "VaultAddParams", + "type": "object" + }, + "VaultAddResult": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "VaultAddResult", + "type": "object" + }, + "VaultCodeRequestParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "site": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Site" + }, + "hint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hint" + } + }, + "required": [ + "session_id" + ], + "title": "VaultCodeRequestParams", + "type": "object" + }, + "VaultItem": { + "additionalProperties": false, + "description": "Metadata-only view (``VaultItemMeta.to_dict`` + ``backend``); never a secret.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "origin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Origin" + }, + "created_at": { + "default": "", + "title": "Created At", + "type": "string" + }, + "identifier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identifier" + }, + "identifier_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identifier Type" + }, + "has_otp": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Has Otp" + }, + "backend": { + "title": "Backend", + "type": "string" + } + }, + "required": [ + "id", + "kind", + "label", + "backend" + ], + "title": "VaultItem", + "type": "object" + }, + "VaultKind": { + "enum": [ + "login", + "payment", + "address" + ], + "title": "VaultKind", + "type": "string" + }, + "VaultListResult": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/VaultItem" + }, + "title": "Items", + "type": "array" + } + }, + "title": "VaultListResult", + "type": "object" + }, + "VaultLockParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + } + }, + "title": "VaultLockParams", + "type": "object" + }, + "VaultLockResult": { + "additionalProperties": false, + "properties": { + "locked": { + "default": true, + "title": "Locked", + "type": "boolean" + } + }, + "title": "VaultLockResult", + "type": "object" + }, + "VaultRemoveParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + } + }, + "title": "VaultRemoveParams", + "type": "object" + }, + "VaultRemoveResult": { + "additionalProperties": false, + "properties": { + "removed": { + "title": "Removed", + "type": "boolean" + } + }, + "required": [ + "removed" + ], + "title": "VaultRemoveResult", + "type": "object" + }, + "VaultSaveLoginRequestParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "origin": { + "title": "Origin", + "type": "string" + }, + "site": { + "title": "Site", + "type": "string" + } + }, + "required": [ + "session_id", + "origin", + "site" + ], + "title": "VaultSaveLoginRequestParams", + "type": "object" + }, + "VaultSource": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "display_name": { + "title": "Display Name", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "needs_unlock": { + "title": "Needs Unlock", + "type": "boolean" + }, + "unlocked": { + "title": "Unlocked", + "type": "boolean" + }, + "installed": { + "title": "Installed", + "type": "boolean" + } + }, + "required": [ + "name", + "display_name", + "enabled", + "needs_unlock", + "unlocked", + "installed" + ], + "title": "VaultSource", + "type": "object" + }, + "VaultSourceSetParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled" + } + }, + "title": "VaultSourceSetParams", + "type": "object" + }, + "VaultSourceSetResult": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + } + }, + "required": [ + "name", + "enabled" + ], + "title": "VaultSourceSetResult", + "type": "object" + }, + "VaultSourcesResult": { + "additionalProperties": false, + "properties": { + "sources": { + "items": { + "$ref": "#/components/schemas/VaultSource" + }, + "title": "Sources", + "type": "array" + } + }, + "title": "VaultSourcesResult", + "type": "object" + }, + "VaultUnlockParams": { + "additionalProperties": false, + "description": "The master password is consumed by the manager CLI and never stored or logged.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "password": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Password" + } + }, + "title": "VaultUnlockParams", + "type": "object" + }, + "VaultUnlockRequestParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "backend": { + "title": "Backend", + "type": "string" + }, + "display_name": { + "title": "Display Name", + "type": "string" + } + }, + "required": [ + "session_id", + "backend", + "display_name" + ], + "title": "VaultUnlockRequestParams", + "type": "object" + }, + "VaultUnlockResult": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "unlocked": { + "default": true, + "title": "Unlocked", + "type": "boolean" + } + }, + "required": [ + "name" + ], + "title": "VaultUnlockResult", + "type": "object" + }, + "VerificationEvidenceRow": { + "additionalProperties": true, + "description": "One ``verification_events`` row (``agent/verification_evidence.py``).", + "properties": { + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + }, + "created_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Created At" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "root": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Root" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "canonical_command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Canonical Command" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Kind" + }, + "scope": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scope" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "exit_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Exit Code" + }, + "output_summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Output Summary" + } + }, + "title": "VerificationEvidenceRow", + "type": "object" + }, + "VerificationStatusInfo": { + "additionalProperties": false, + "description": "``verification_status()``: ``disabled`` / ``not_applicable`` / ``unverified`` / ``stale`` or the\nlatest event's own status; ``root`` and friends only once a workspace was identified.", + "properties": { + "status": { + "title": "Status", + "type": "string" + }, + "evidence": { + "anyOf": [ + { + "$ref": "#/components/schemas/VerificationEvidenceRow" + }, + { + "type": "null" + } + ], + "default": null + }, + "root": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Root" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "changed_paths": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Changed Paths" + } + }, + "required": [ + "status" + ], + "title": "VerificationStatusInfo", + "type": "object" + }, + "VerificationStatusParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "session_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Key" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + } + }, + "title": "VerificationStatusParams", + "type": "object" + }, + "VerificationStatusResult": { + "additionalProperties": false, + "properties": { + "verification": { + "$ref": "#/components/schemas/VerificationStatusInfo" + } + }, + "required": [ + "verification" + ], + "title": "VerificationStatusResult", + "type": "object" + }, + "VoiceRecordAction": { + "enum": [ + "start", + "stop" + ], + "title": "VoiceRecordAction", + "type": "string" + }, + "VoiceRecordParams": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/VoiceRecordAction", + "default": "start" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "VoiceRecordParams", + "type": "object" + }, + "VoiceRecordResult": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/components/schemas/VoiceRecordStatus" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "status" + ], + "title": "VoiceRecordResult", + "type": "object" + }, + "VoiceRecordStatus": { + "enum": [ + "recording", + "stopped", + "busy" + ], + "title": "VoiceRecordStatus", + "type": "string" + }, + "VoiceStatusPayload": { + "additionalProperties": false, + "description": "``methods_voice._vr_on_status``; states come from the recorder (idle / listening / transcribing \u2026).", + "properties": { + "state": { + "title": "State", + "type": "string" + } + }, + "required": [ + "state" + ], + "title": "VoiceStatusPayload", + "type": "object" + }, + "VoiceToggleAction": { + "enum": [ + "status", + "on", + "off", + "tts" + ], + "title": "VoiceToggleAction", + "type": "string" + }, + "VoiceToggleParams": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/VoiceToggleAction", + "default": "status" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "VoiceToggleParams", + "type": "object" + }, + "VoiceToggleResult": { + "additionalProperties": false, + "description": "``methods_voice.py::_voice_status_payload`` (+ the requirements probe on ``status``, the spoken\nstop hint on ``on``).", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "record_key": { + "title": "Record Key", + "type": "string" + }, + "tts": { + "title": "Tts", + "type": "boolean" + }, + "stop_hint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stop Hint" + }, + "available": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Available" + }, + "audio_available": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Audio Available" + }, + "stt_available": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stt Available" + }, + "details": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Details" + } + }, + "required": [ + "enabled", + "record_key", + "tts" + ], + "title": "VoiceToggleResult", + "type": "object" + }, + "VoiceTranscriptPayload": { + "additionalProperties": false, + "description": "``methods_voice._vr_transcript`` / ``_deliver_fd_transcript`` / typed stop phrase in methods_prompt.", + "properties": { + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "stop_phrase": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stop Phrase" + }, + "typed": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Typed" + }, + "no_speech_limit": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "No Speech Limit" + } + }, + "title": "VoiceTranscriptPayload", + "type": "object" + }, + "VoiceTtsParams": { + "additionalProperties": false, + "properties": { + "text": { + "title": "Text", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "text" + ], + "title": "VoiceTtsParams", + "type": "object" + }, + "VoiceTtsResult": { + "additionalProperties": false, + "properties": { + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "status" + ], + "title": "VoiceTtsResult", + "type": "object" + }, + "WaitBarrierTarget": { + "additionalProperties": false, + "properties": { + "type": { + "enum": [ + "session", + "pid" + ], + "title": "Type", + "type": "string" + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ], + "title": "Target" + }, + "reason": { + "default": "", + "title": "Reason", + "type": "string" + } + }, + "required": [ + "type", + "target" + ], + "title": "WaitBarrierTarget", + "type": "object" + }, + "WaitBarrierUntil": { + "additionalProperties": false, + "properties": { + "type": { + "const": "until", + "title": "Type", + "type": "string" + }, + "until_at": { + "title": "Until At", + "type": "number" + }, + "reason": { + "default": "", + "title": "Reason", + "type": "string" + } + }, + "required": [ + "type", + "until_at" + ], + "title": "WaitBarrierUntil", + "type": "object" + }, + "WakeControlParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "WakeControlParams", + "type": "object" + }, + "WakeDetectedPayload": { + "additionalProperties": false, + "description": "``methods_voice`` wake detector ``_on_detect``.", + "properties": { + "phrase": { + "title": "Phrase", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "start_new_session": { + "title": "Start New Session", + "type": "boolean" + } + }, + "required": [ + "phrase", + "start_new_session" + ], + "title": "WakeDetectedPayload", + "type": "object" + }, + "WakeFeedParams": { + "additionalProperties": false, + "description": "``pcm`` (or the ``pcm_b64`` alias): base64 int16 mono little-endian, 16 kHz only.", + "properties": { + "pcm": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pcm" + }, + "pcm_b64": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pcm B64" + }, + "sample_rate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sample Rate" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "WakeFeedParams", + "type": "object" + }, + "WakeFeedResult": { + "additionalProperties": false, + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "fed": { + "title": "Fed", + "type": "boolean" + } + }, + "required": [ + "fed" + ], + "title": "WakeFeedResult", + "type": "object" + }, + "WakeInputDevice": { + "additionalProperties": true, + "description": "``tools/wake_word.py::_describe_input_device`` \u2014 PortAudio diagnostics for the configured mic.", + "properties": { + "selector": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Selector" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "max_input_channels": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Input Channels" + }, + "default_samplerate": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Default Samplerate" + }, + "hostapi_index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hostapi Index" + }, + "hostapi": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hostapi" + } + }, + "title": "WakeInputDevice", + "type": "object" + }, + "WakePauseResult": { + "additionalProperties": false, + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "paused": { + "title": "Paused", + "type": "boolean" + } + }, + "required": [ + "paused" + ], + "title": "WakePauseResult", + "type": "object" + }, + "WakeResumeResult": { + "additionalProperties": false, + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "resumed": { + "title": "Resumed", + "type": "boolean" + } + }, + "required": [ + "resumed" + ], + "title": "WakeResumeResult", + "type": "object" + }, + "WakeStartParams": { + "additionalProperties": false, + "description": "``surface`` names the caller (\"tui\" | \"gui\"); ``persist`` is the explicit gesture that also\nflips ``wake_word.enabled`` on; ``client_capture`` asks for PCM streamed via wake.feed.", + "properties": { + "surface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Surface" + }, + "persist": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Persist" + }, + "client_capture": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Client Capture" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "WakeStartParams", + "type": "object" + }, + "WakeStartResult": { + "additionalProperties": false, + "description": "``started: false`` carries ``reason`` (unavailable / disabled / disabled_for_surface / owned);\n``sample_rate`` / ``frame_length`` describe the PCM frames the armed detector expects.", + "properties": { + "started": { + "title": "Started", + "type": "boolean" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "hint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hint" + }, + "phrase": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Phrase" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "owner_surface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Owner Surface" + }, + "enabled_persisted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled Persisted" + }, + "capture": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Capture" + }, + "sample_rate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sample Rate" + }, + "frame_length": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Frame Length" + } + }, + "required": [ + "started" + ], + "title": "WakeStartResult", + "type": "object" + }, + "WakeStatusParams": { + "additionalProperties": false, + "properties": { + "surface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Surface" + }, + "client_capture": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Client Capture" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "WakeStatusParams", + "type": "object" + }, + "WakeStatusResult": { + "additionalProperties": false, + "description": "``enabled`` is config truth; ``listening`` is this caller's armed detector; ``audio_silent``\nmeans armed but deaf (see ``hint``).", + "properties": { + "listening": { + "title": "Listening", + "type": "boolean" + }, + "owned_by_caller": { + "title": "Owned By Caller", + "type": "boolean" + }, + "owner_surface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Owner Surface" + }, + "phrase": { + "title": "Phrase", + "type": "string" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "configured_surface": { + "title": "Configured Surface", + "type": "string" + }, + "input_device": { + "$ref": "#/components/schemas/WakeInputDevice" + }, + "available": { + "title": "Available", + "type": "boolean" + }, + "hint": { + "title": "Hint", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "audio_silent": { + "title": "Audio Silent", + "type": "boolean" + }, + "capture": { + "title": "Capture", + "type": "string" + }, + "local_input_available": { + "title": "Local Input Available", + "type": "boolean" + }, + "sample_rate": { + "title": "Sample Rate", + "type": "integer" + }, + "frame_length": { + "title": "Frame Length", + "type": "integer" + } + }, + "required": [ + "listening", + "owned_by_caller", + "phrase", + "provider", + "configured_surface", + "input_device", + "available", + "hint", + "enabled", + "audio_silent", + "capture", + "local_input_available", + "sample_rate", + "frame_length" + ], + "title": "WakeStatusResult", + "type": "object" + }, + "WakeStopParams": { + "additionalProperties": false, + "properties": { + "persist": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Persist" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "WakeStopParams", + "type": "object" + }, + "WakeStopResult": { + "additionalProperties": false, + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "stopped": { + "title": "Stopped", + "type": "boolean" + }, + "disabled_persisted": { + "title": "Disabled Persisted", + "type": "boolean" + } + }, + "required": [ + "stopped", + "disabled_persisted" + ], + "title": "WakeStopResult", + "type": "object" + }, + "_SessionScoped": { + "additionalProperties": false, + "description": "Handlers that look a live session up with ``_sessions.get(params.get(\"session_id\"))``: an\nabsent / unknown id falls back to the launch profile's config, so it is never required.", + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "title": "_SessionScoped", + "type": "object" + }, + "ConnectorErrorReason": { + "type": "string", + "enum": [ + "INVALID_PARAMS", + "NOT_OWNER", + "UNSUPPORTED_RUNTIME", + "CONNECTOR_REQUEST_FAILED", + "INVALID_CONNECTOR_RESPONSE", + "UNKNOWN_TARGET", + "LINK_STILL_VALID", + "REISSUE_REFUSED", + "UNKNOWN_OPERATION", + "INVALID_ANSWER", + "NEEDS_NOUS_AUTH", + "CONNECTOR_NOT_FOUND", + "TOOLS_UNAVAILABLE", + "CONNECTORS_UNAVAILABLE", + "CATALOG_UNAVAILABLE", + "ACCOUNTS_UNAVAILABLE", + "CONNECTION_NOT_FOUND", + "POLICY_UNAVAILABLE", + "POLICY_CONFLICT", + "FORBIDDEN_SCOPE", + "ORG_REQUIRED", + "ORG_ACCESS_DENIED", + "INVALID_POLICY" + ] + } + } + }, + "x-server-requests": [ + { + "name": "approval", + "summary": "A dangerous command awaits the user's decision.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ApprovalRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ApprovalResult" + } + } + }, + { + "name": "clarify", + "summary": "The clarify tool: ask the user one question or a batch.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ClarifyRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ClarifyResult" + } + } + }, + { + "name": "display.install.sudo", + "summary": "Masked sudo password for the Bot Screen package install; app-level (empty session).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DisplayInstallSudoParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "preview.act", + "summary": "Click / type / scroll / annotate inside the in-app browser preview.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PreviewActRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "preview.read", + "summary": "Read the in-app browser preview's text (JSON text answer).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ReadRangeRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "secret", + "summary": "Masked value for a named env var (skills / setup flows).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SecretRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "sudo", + "summary": "Masked sudo password for the terminal tool.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SudoRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "terminal.read", + "summary": "Read the visible in-app terminal buffer (JSON text answer).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ReadRangeRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "tour", + "summary": "Drive a guided tour highlight in the desktop renderer.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/TourRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "vault.code", + "summary": "A one-time / 2FA code the user reads from their device.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultCodeRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "vault.save_login", + "summary": "Save a login for a site the agent is about to fill; the answer is JSON {identifier, password}.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultSaveLoginRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "vault.unlock_prompt", + "summary": "Master password to unlock an external password manager for this session.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultUnlockRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "window.read", + "summary": "Enumerate the native window below the app (JSON text answer).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/EmptyRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + } + ], + "x-notifications": [ + { + "name": "agent.terminal.output", + "summary": "Output chunk from an agent-owned background process.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/TerminalOutputPayload" + } + } + ] + }, + { + "name": "background.complete", + "summary": "A /background side agent finished.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SideAgentCompletePayload" + } + } + ] + }, + { + "name": "billing.step_up.verification", + "summary": "Device-flow URL + code for the billing scope step-up; the client opens the browser.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/BillingStepUpVerificationPayload" + } + } + ] + }, + { + "name": "bot_relay.outbox.pending", + "summary": "A bot-relay outbox envelope is queued; drain it.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ChangeSignalPayload" + } + } + ] + }, + { + "name": "browser.controller.cancel", + "summary": "Withdraw a pending controller command.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/BrowserControllerCancelPayload" + } + } + ] + }, + { + "name": "browser.controller.command", + "summary": "Dispatch one browser action to the attached controller.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/BrowserControllerCommandPayload" + } + } + ] + }, + { + "name": "browser.progress", + "summary": "Browser (CDP) connect / install progress line.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/BrowserProgressPayload" + } + } + ] + }, + { + "name": "btw.complete", + "summary": "A /btw side question was answered.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SideAgentCompletePayload" + } + } + ] + }, + { + "name": "connection.request", + "summary": "A connection operation opened on this session; the desktop renders its card.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ConnectionRequestPayload" + } + } + ] + }, + { + "name": "connection.update", + "summary": "One transition or the settlement of an open connection operation.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ConnectionUpdatePayload" + } + } + ] + }, + { + "name": "cron.changed", + "summary": "cron/jobs.json moved; refetch the cron list.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ChangeSignalPayload" + } + } + ] + }, + { + "name": "display.install.done", + "summary": "The install ended (0 ok, -1 cancelled, -2 no sudo: the command to run by hand was streamed).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/DisplayInstallDonePayload" + } + } + ] + }, + { + "name": "display.install.log", + "summary": "One line of package-manager output.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/DisplayInstallLogPayload" + } + } + ] + }, + { + "name": "display.lease", + "summary": "The takeover lease changed hands; every client repaints.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/DisplayLeasePayload" + } + } + ] + }, + { + "name": "display.status", + "summary": "This profile's screen started or stopped (also for transitions made outside hermes serve).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/DisplayStatusPayload" + } + } + ] + }, + { + "name": "error", + "summary": "A session-level failure outside a turn (agent init, model switch, compression, resume).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ErrorPayload" + } + } + ] + }, + { + "name": "gateway.ready", + "summary": "First frame of a connection: the resolved skin, the change-event capability and the replay epoch.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/GatewayReadyPayload" + } + } + ] + }, + { + "name": "layout.apply", + "summary": "Apply a named desktop layout preset.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/LayoutApplyPayload" + } + } + ] + }, + { + "name": "message.complete", + "summary": "The turn ended: final text, usage and outcome.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/MessageCompletePayload" + } + } + ] + }, + { + "name": "message.delta", + "summary": "One streamed chunk of the assistant reply.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/StreamDeltaPayload" + } + } + ] + }, + { + "name": "message.interim", + "summary": "Interim assistant commentary (text beside tool calls) sealed as its own segment.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/MessageInterimPayload" + } + } + ] + }, + { + "name": "message.reaction", + "summary": "The agent reacted to a message; paint it live.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/MessageReactionPayload" + } + } + ] + }, + { + "name": "message.start", + "summary": "A turn began streaming; no payload.", + "params": [ + { + "name": "payload", + "schema": { + "type": "object", + "additionalProperties": false + } + } + ] + }, + { + "name": "moa.aggregating", + "summary": "The MoA aggregator started.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/MoaAggregatingPayload" + } + } + ] + }, + { + "name": "moa.phase", + "summary": "MoA phase transition (currently only ``aggregator``).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/MoaPhasePayload" + } + } + ] + }, + { + "name": "moa.progress", + "summary": "MoA reference fan-out progress (n/total).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/MoaProgressPayload" + } + } + ] + }, + { + "name": "moa.reference", + "summary": "One MoA reference model's output.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/MoaReferencePayload" + } + } + ] + }, + { + "name": "notice", + "summary": "Informational one-liner for the session (capabilities refreshed).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/NoticePayload" + } + } + ] + }, + { + "name": "notification.clear", + "summary": "Withdraw the notice with this key.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/NotificationClearPayload" + } + } + ] + }, + { + "name": "notification.show", + "summary": "Show / replace a keyed out-of-band notice (toast or status bar).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/NotificationShowPayload" + } + } + ] + }, + { + "name": "pairing.changed", + "summary": "Pairing state moved; refetch pairing.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ChangeSignalPayload" + } + } + ] + }, + { + "name": "pane.reveal", + "summary": "Focus / reveal a named desktop pane.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/PaneRevealPayload" + } + } + ] + }, + { + "name": "pet.changed", + "summary": "The active pet / its spritesheet changed (watcher).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/PetChangedPayload" + } + } + ] + }, + { + "name": "pet.generate.progress", + "summary": "Pet base-draft generation progress.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/PetGenerateProgressPayload" + } + } + ] + }, + { + "name": "pet.hatch.progress", + "summary": "Pet hatch (row drawing) progress.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/PetHatchProgressPayload" + } + } + ] + }, + { + "name": "platforms.changed", + "summary": "gateway_state.json moved; refetch platform status.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ChangeSignalPayload" + } + } + ] + }, + { + "name": "preview.close", + "summary": "Close the preview pane or one tab.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/PreviewClosePayload" + } + } + ] + }, + { + "name": "preview.open", + "summary": "Open a URL / file in the desktop preview pane.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/PreviewOpenPayload" + } + } + ] + }, + { + "name": "preview.restart.complete", + "summary": "The hidden preview-restart agent finished.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SideAgentCompletePayload" + } + } + ] + }, + { + "name": "preview.restart.progress", + "summary": "Progress line from the preview-restart agent.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/PreviewRestartProgressPayload" + } + } + ] + }, + { + "name": "projects.changed", + "summary": "projects.db moved; refetch the project list + tree.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ChangeSignalPayload" + } + } + ] + }, + { + "name": "reaction", + "summary": "Affection reaction detected in the user's message (hearts etc.).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ReactionPayload" + } + } + ] + }, + { + "name": "reasoning.available", + "summary": "A completed reasoning block (non-streaming providers).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/StreamDeltaPayload" + } + } + ] + }, + { + "name": "reasoning.delta", + "summary": "One streamed chunk of the model's reasoning.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/StreamDeltaPayload" + } + } + ] + }, + { + "name": "request.cancel", + "summary": "The backend withdrew an open server\u2192client request; clear the matching card only.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/RequestCancelPayload" + } + } + ] + }, + { + "name": "review.summary", + "summary": "Background review of the last turn finished.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ReviewSummaryPayload" + } + } + ] + }, + { + "name": "session.control.update", + "summary": "Persisted goal / loop / heartbeat state changed.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SessionControlUpdatePayload" + } + } + ] + }, + { + "name": "session.info", + "summary": "Live session settings snapshot (``server._session_info``); also the ``info`` of create/resume/activate.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SessionLiveInfo" + } + } + ] + }, + { + "name": "session.reclaimed", + "summary": "The backend reclaimed a live session out from under its clients.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SessionReclaimedPayload" + } + } + ] + }, + { + "name": "session.resume_progress", + "summary": "Deferred resume hydration progress.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SessionResumeProgressPayload" + } + } + ] + }, + { + "name": "session.title", + "summary": "Auto-titling renamed the session (``session_id`` is the stored key).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SessionTitlePayload" + } + } + ] + }, + { + "name": "session.usage", + "summary": "Mid-turn usage tick; message.complete carries the authoritative final usage.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SessionUsagePayload" + } + } + ] + }, + { + "name": "sessions.changed", + "summary": "state.db moved; refetch the session list.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ChangeSignalPayload" + } + } + ] + }, + { + "name": "setup.ready", + "summary": "The free-tier bootstrap finished (broadcast); the desktop's setup gate reads the record.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SetupReadyPayload" + } + } + ] + }, + { + "name": "skin.changed", + "summary": "The active skin moved (name switch or live colour edit); repaint from this palette.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SkinPayload" + } + } + ] + }, + { + "name": "status.update", + "summary": "Transient status line (kind: status, lifecycle, compacting, goal, loop, heartbeat, process, \u2026).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/StatusUpdatePayload" + } + } + ] + }, + { + "name": "subagent.complete", + "summary": "A child finished (status + observability rollup).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SubagentEventPayload" + } + } + ] + }, + { + "name": "subagent.progress", + "summary": "Batched tool-name progress from a child.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SubagentEventPayload" + } + } + ] + }, + { + "name": "subagent.spawn_requested", + "summary": "delegate_task accepted a child goal (before the child starts).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SubagentEventPayload" + } + } + ] + }, + { + "name": "subagent.start", + "summary": "A delegated child started running.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SubagentEventPayload" + } + } + ] + }, + { + "name": "subagent.thinking", + "summary": "A child's reasoning chunk.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SubagentEventPayload" + } + } + ] + }, + { + "name": "subagent.tool", + "summary": "A child called a tool.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SubagentEventPayload" + } + } + ] + }, + { + "name": "terminal.close", + "summary": "An agent-owned background process closed.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/TerminalClosePayload" + } + } + ] + }, + { + "name": "thinking.delta", + "summary": "Legacy thinking-text chunk (thinking_callback).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/StreamDeltaPayload" + } + } + ] + }, + { + "name": "tip.show", + "summary": "Point at a desktop element with a one-line tip bubble.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/TipShowPayload" + } + } + ] + }, + { + "name": "todo.updated", + "summary": "Full todo snapshot after a todo tool ran.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/TodoUpdatedPayload" + } + } + ] + }, + { + "name": "tool.complete", + "summary": "A tool call finished: parsed result, summary, optional diff / todo snapshot.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ToolCompletePayload" + } + } + ] + }, + { + "name": "tool.generating", + "summary": "The model is emitting a tool call's arguments.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ToolGeneratingPayload" + } + } + ] + }, + { + "name": "tool.output_risk", + "summary": "Tool output was classified as risky (prompt-injection / secret findings).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ToolOutputRiskPayload" + } + } + ] + }, + { + "name": "tool.start", + "summary": "A tool call began (stable id + full args).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ToolStartPayload" + } + } + ] + }, + { + "name": "voice.interrupted", + "summary": "Barge-in: the spoken interjection interrupted the turn; no payload.", + "params": [ + { + "name": "payload", + "schema": { + "type": "object", + "additionalProperties": false + } + } + ] + }, + { + "name": "voice.status", + "summary": "Voice recorder state changed.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/VoiceStatusPayload" + } + } + ] + }, + { + "name": "voice.transcript", + "summary": "A voice capture produced text (or a stop phrase / silence limit).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/VoiceTranscriptPayload" + } + } + ] + }, + { + "name": "wake.detected", + "summary": "A wake phrase fired.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/WakeDetectedPayload" + } + } + ] + } + ] +} diff --git a/scripts/sync-gateway-contract.sh b/scripts/sync-gateway-contract.sh new file mode 100755 index 00000000..f7e0b3fc --- /dev/null +++ b/scripts/sync-gateway-contract.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Refresh the vendored gateway contract snapshot from hermes-agent (#1374). +# Usage: scripts/sync-gateway-contract.sh [ref] (default: main) +set -euo pipefail + +REF="${1:-main}" +REPO="NousResearch/hermes-agent" +CONTRACT_PATH="apps/shared/src/gateway-contract.openrpc.json" +DEST="$(cd "$(dirname "$0")/.." && pwd)/app/src/test/resources/gateway-contract" + +SHA="$(gh api "repos/$REPO/commits/$REF" --jq .sha)" +DATE="$(gh api "repos/$REPO/commits/$SHA" --jq .commit.committer.date)" + +mkdir -p "$DEST" +gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$CONTRACT_PATH?ref=$SHA" > "$DEST/openrpc.json" +jq -e '.methods | length > 0' "$DEST/openrpc.json" > /dev/null + +printf 'repo: %s\nref: %s\nsha: %s\ndate: %s\npath: %s\n' "$REPO" "$REF" "$SHA" "${DATE%%T*}" "$CONTRACT_PATH" > "$DEST/SOURCE" +echo "Vendored $REPO@$SHA. Run ./gradlew testDebugUnitTest --tests '*GatewayContract*' and review the diff." From d466414563ccc90235e605b7120de0a723f70eae Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:35:10 +0300 Subject: [PATCH 065/107] refactor(#1375): typed RPC foundation and session.events.since (#1380) --- .../hermescontrol/data/ws/HermesWsClient.kt | 125 ++-- .../data/ws/contract/RpcMethod.kt | 22 + .../data/ws/contract/SessionEventsSince.kt | 20 + .../hermescontrol/data/ws/GatewayContract.kt | 78 +++ .../data/ws/GatewayContractTest.kt | 15 + .../data/ws/HermesWsClientTypedCallTest.kt | 534 ++++++++++++++++++ .../data/ws/RawRpcParamsRatchetTest.kt | 402 +++++++++++++ .../ws/contract/ParamsDescriptorCheckTest.kt | 106 ++++ .../contract/SessionEventsSinceTypesTest.kt | 126 +++++ 9 files changed, 1377 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSince.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSinceTypesTest.kt 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 09f3166a..acc2c633 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 @@ -10,6 +10,9 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.await import com.m57.hermescontrol.data.session.ActiveSessionHolder +import com.m57.hermescontrol.data.ws.contract.RpcMethod +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -821,6 +824,34 @@ object HermesWsClient { return deferred } + /** + * Send a typed JSON-RPC request and await its deserialized result. + * + * Params classes are strict writers, result classes are tolerant readers, and + * profile is never a field in params (it is injected by WsProfileParams). + */ + suspend fun call( + method: RpcMethod, + params: P, + timeoutMs: Long = REQUEST_TIMEOUT_MS, + suppressErrorEvent: Boolean = false, + ): R { + val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params) + require(encoded is JsonObject) { + "RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}" + } + val deferred = request(method.name, encoded, timeoutMs, suppressErrorEvent) + val result = + try { + deferred.await() + } catch (e: CancellationException) { + deferred.cancel(e) + throw e + } + val element = result.toJsonElement() + return OkHttpProvider.json.decodeFromJsonElement(method.result, element) + } + /** Complete (or fail) a single pending call and cancel its timer. */ private fun resolvePending( id: String, @@ -1093,63 +1124,55 @@ object HermesWsClient { for (sid in sessionsToReplay) { val lastSeen = lastSeenSeq[sid] ?: continue try { - val deferred = - request( - method = WsMethods.SESSION_EVENTS_SINCE, - params = mapOf("session_id" to sid, "last_seen" to lastSeen), + val res = + call( + RpcMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams(sessionId = sid, lastSeen = lastSeen), timeoutMs = 10_000L, ) - val result = deferred.await() - @Suppress("UNCHECKED_CAST") - val resultMap = - when (result) { - is JsonElement -> result.toAny() as? Map - is Map<*, *> -> result as? Map - else -> null - } - if (resultMap != null) { - val epoch = resultMap["epoch"] as? String - val epochChanged = - !epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch - val truncated = resultMap["truncated"] == true - val latestSeq = (resultMap["latest_seq"] as? Number)?.toInt() + val epoch = res.epoch + val epochChanged = + !epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch + val truncated = res.truncated == true + val latestSeq = res.latestSeq - if (epochChanged) replayEpoch = epoch + if (epochChanged) replayEpoch = epoch - if (epochChanged || truncated) { - // Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted). - // Partial replay would silently hole the transcript — fast-forward watermark - // and request full transcript resync. - if (epochChanged) lastSeenSeq.clear() - if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq - parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid)) - continue - } - val eventsList = (resultMap["events"] as? List<*>)?.filterIsInstance>() - if (eventsList != null) { - for (eventMap in eventsList) { - val eventSeq = (eventMap["seq"] as? Number)?.toInt() - val eventSid = (eventMap["session_id"] as? String) ?: sid - if (eventSeq != null && eventSid.isNotEmpty()) { - val prev = lastSeenSeq[eventSid] ?: 0 - if (eventSeq <= prev) continue - lastSeenSeq[eventSid] = eventSeq - } - val parsedEvent = EventParser.parseParams(eventMap) - val finalEvent = - if (parsedEvent is WsEvent.MessageComplete) { - parsedEvent.copy( - storedSessionId = - ActiveSessionHolder.resolveStoredSessionId( - parsedEvent.sessionId, - ), - ) - } else { - parsedEvent - } - parsedEvents.tryEmit(finalEvent) + if (epochChanged || truncated) { + // Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted). + // Partial replay would silently hole the transcript — fast-forward watermark + // and request full transcript resync. + if (epochChanged) lastSeenSeq.clear() + if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq + parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid)) + continue + } + val eventsList = res.events + if (eventsList != null) { + for (element in eventsList) { + @Suppress("UNCHECKED_CAST") + val eventMap = (element as? JsonObject)?.toAny() as? Map ?: continue + val eventSeq = (eventMap["seq"] as? Number)?.toInt() + val eventSid = (eventMap["session_id"] as? String) ?: sid + if (eventSeq != null && eventSid.isNotEmpty()) { + val prev = lastSeenSeq[eventSid] ?: 0 + if (eventSeq <= prev) continue + lastSeenSeq[eventSid] = eventSeq } + val parsedEvent = EventParser.parseParams(eventMap) + val finalEvent = + if (parsedEvent is WsEvent.MessageComplete) { + parsedEvent.copy( + storedSessionId = + ActiveSessionHolder.resolveStoredSessionId( + parsedEvent.sessionId, + ), + ) + } else { + parsedEvent + } + parsedEvents.tryEmit(finalEvent) } } } catch (e: Exception) { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt new file mode 100644 index 00000000..4288387d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -0,0 +1,22 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.serialization.KSerializer + +class RpcMethod( + val name: String, + val params: KSerializer

, + val result: KSerializer, +) + +object RpcMethods { + val SESSION_EVENTS_SINCE: RpcMethod = + RpcMethod( + WsMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams.serializer(), + SessionEventsSinceResult.serializer(), + ) + + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ + val all: List> = listOf(SESSION_EVENTS_SINCE) +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSince.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSince.kt new file mode 100644 index 00000000..57cebe0b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSince.kt @@ -0,0 +1,20 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class SessionEventsSinceParams( + @SerialName("session_id") val sessionId: String, + @SerialName("last_seen") val lastSeen: Int? = null, +) // NO profile field: profile is injected by WsProfileParams only. + +// Tolerant reader: every field nullable/defaulted, mirrors what the old untyped parser tolerated. +@Serializable +data class SessionEventsSinceResult( + val events: List? = null, + @SerialName("latest_seq") val latestSeq: Int? = null, + val truncated: Boolean? = null, + val epoch: String? = null, +) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt index 7a4886ac..a5d9530b 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt @@ -1,8 +1,10 @@ package com.m57.hermescontrol.data.ws +import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.boolean import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject @@ -70,6 +72,82 @@ object GatewayContract { required.filter { it !in keys }.forEach { problems += "$method: missing required param '$it'" } return problems } + + /** + * Verifies that a Kotlinx [descriptor] for [method]'s params matches the published contract. + * Empty list means OK. + */ + fun paramsDescriptorProblems( + method: String, + descriptor: SerialDescriptor, + ): List { + if (method !in methods) return listOf("$method is not a contract method") + val schema = paramsSchema(method) + val propertiesObj = schema["properties"]?.jsonObject ?: JsonObject(emptyMap()) + val properties = propertiesObj.keys + val required = + schema["required"] + ?.jsonArray + ?.map { it.jsonPrimitive.content } + ?.toSet() + .orEmpty() + val problems = mutableListOf() + + val elementNames = (0 until descriptor.elementsCount).map { descriptor.getElementName(it) }.toSet() + + for (i in 0 until descriptor.elementsCount) { + val name = descriptor.getElementName(i) + if (name !in properties) { + problems += + "$method: unknown param '$name' (backend forbids extra keys; allowed: ${properties.sorted()})" + } + } + + val missingRequired = + required.filter { reqName -> + reqName !in elementNames && reqName != "profile" + } + for (missing in missingRequired) { + problems += "$method: missing required param '$missing'" + } + + for (i in 0 until descriptor.elementsCount) { + val name = descriptor.getElementName(i) + if (name in required) { + if (descriptor.isElementOptional(i)) { + problems += "$method: required param '$name' must not be optional" + } + val propSchema = propertiesObj[name]?.jsonObject + val allowsNull = propSchema?.let { propertyAllowsNull(it) } ?: false + if (descriptor.getElementDescriptor(i).isNullable && !allowsNull) { + problems += "$method: required param '$name' must not be nullable unless schema allows null" + } + } + } + + return problems + } + + private fun propertyAllowsNull(propertySchema: JsonObject): Boolean { + val typeElement = propertySchema["type"] + if (typeElement != null) { + if (typeElement is JsonArray && typeElement.any { it.jsonPrimitive.content == "null" }) { + return true + } + if (typeElement is JsonPrimitive && typeElement.content == "null") { + return true + } + } + val anyOf = propertySchema["anyOf"]?.jsonArray + if (anyOf != null && anyOf.any { it.jsonObject["type"]?.jsonPrimitive?.content == "null" }) { + return true + } + val oneOf = propertySchema["oneOf"]?.jsonArray + if (oneOf != null && oneOf.any { it.jsonObject["type"]?.jsonPrimitive?.content == "null" }) { + return true + } + return false + } } /** diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt index 033adc15..be06f9c6 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.ws +import com.m57.hermescontrol.data.ws.contract.RpcMethods import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -75,6 +76,20 @@ class GatewayContractTest { } } + @Test + fun typedParamsMatchTheContract() { + val methods = RpcMethods.all + assertTrue("RpcMethods.all must not be empty", methods.isNotEmpty()) + val names = methods.map { it.name } + assertEquals("RpcMethods.all names must be unique", names.distinct(), names) + + val problems = + methods.flatMap { method -> + GatewayContract.paramsDescriptorProblems(method.name, method.params.descriptor) + } + assertTrue(problems.joinToString("\n"), problems.isEmpty()) + } + private companion object { /** Methods ChatViewModel.handleServerRequest routes; keep in sync with its `when`. */ val APP_HANDLED_SERVER_REQUESTS = diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt new file mode 100644 index 00000000..9c7e23df --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt @@ -0,0 +1,534 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.RpcMethod +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientTypedCallTest { + private lateinit var mockWebServer: MockWebServer + + @Serializable + private data class ScopedDummyParams( + val query: String, + val profile: String? = null, + ) + + @Serializable + private data class ScopedDummyResult( + val status: String? = null, + ) + + private val sessionListMethod = + RpcMethod( + name = WsMethods.SESSION_LIST, + params = ScopedDummyParams.serializer(), + result = ScopedDummyResult.serializer(), + ) + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Suppress("UNCHECKED_CAST") + private fun getPendingCalls(): ConcurrentHashMap { + val field = HermesWsClient::class.java.getDeclaredField("pendingCalls") + field.isAccessible = true + return field.get(HermesWsClient) as ConcurrentHashMap + } + + @Test + fun callPutsExactFrameOnTheWire() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == WsMethods.SESSION_EVENTS_SINCE) { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":{"latest_seq":7,"truncated":false}}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams(sessionId = "s1", lastSeen = 7), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals(WsMethods.SESSION_EVENTS_SINCE, (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "s1") + put("last_seen", 7) + } + assertEquals(expectedParams, params) + + val lastSeenElement = params!!["last_seen"] as? JsonPrimitive + assertNotNull(lastSeenElement) + assertTrue(lastSeenElement!!.isString.not()) + assertEquals(7, lastSeenElement.content.toInt()) + } + + @Test + fun callDecodesTypedResultOnSuccess() { + val opened = CountDownLatch(1) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":{"epoch":"ep-42","latest_seq":100,"truncated":true,"events":[{"type":"test"}]}}""", + ) + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams(sessionId = "s1", lastSeen = 5), + ) + } + + assertEquals("ep-42", result.epoch) + assertEquals(100, result.latestSeq) + assertEquals(true, result.truncated) + assertEquals(1, result.events?.size) + } + + @Test + fun callThrowsHermesRpcExceptionOnErrorResponse() { + val opened = CountDownLatch(1) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","error":{"code":4001,"message":"Access denied: session ownership required","data":{"reason":"NOT_OWNER"}}}""", + ) + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + try { + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams(sessionId = "s1", lastSeen = 1), + ) + } + fail("Expected HermesRpcException to be thrown") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals(4001, e.code) + assertEquals("Access denied: session ownership required", e.message) + val dataObj = e.data as? JsonObject + assertNotNull(dataObj) + assertEquals("NOT_OWNER", (dataObj!!["reason"] as? JsonPrimitive)?.content) + } + } + + @Test + fun profileScopingInjectsActiveProfileWhenSet() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == WsMethods.SESSION_LIST) { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""") + } + } + }, + ), + ) + + AuthManager.setActiveProfileId("work-profile") + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + runBlocking { + HermesWsClient.call( + sessionListMethod, + ScopedDummyParams(query = "active"), + ) + } + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + assertEquals("work-profile", (params!!["profile"] as? JsonPrimitive)?.content) + assertEquals("active", (params["query"] as? JsonPrimitive)?.content) + } + + @Test + fun profileScopingPreservesExplicitProfile() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == WsMethods.SESSION_LIST) { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""") + } + } + }, + ), + ) + + AuthManager.setActiveProfileId("work-profile") + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + runBlocking { + HermesWsClient.call( + sessionListMethod, + ScopedDummyParams(query = "active", profile = "explicit-override"), + ) + } + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + assertEquals("explicit-override", (params!!["profile"] as? JsonPrimitive)?.content) + } + + @Test + fun profileScopingDoesNotInjectWhenNoActiveProfile() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == WsMethods.SESSION_LIST) { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""") + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + runBlocking { + HermesWsClient.call( + sessionListMethod, + ScopedDummyParams(query = "active"), + ) + } + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + assertFalse("Profile key must not be present when no active profile", params!!.containsKey("profile")) + } + + @Test + fun callFailsOnTimeoutWhenServerDoesNotReply() { + val opened = CountDownLatch(1) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + // Do not reply + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val thrown = + runCatching { + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams(sessionId = "s1", lastSeen = 1), + timeoutMs = 50L, + ) + } + }.exceptionOrNull() + + assertTrue( + "Timeout must surface as HermesRpcException, got: $thrown", + thrown is HermesWsClient.HermesRpcException, + ) + val rpc = thrown as HermesWsClient.HermesRpcException + assertEquals(-1, rpc.code) + assertTrue(rpc.message.orEmpty().contains("timed out")) + } + + @Test + fun cancellingCallingCoroutineCleansPendingCall() { + val opened = CountDownLatch(1) + val requestReceived = CountDownLatch(1) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + requestReceived.countDown() + // Do not reply, keeping the call pending + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + runBlocking { + val callJob = + async(Dispatchers.IO) { + HermesWsClient.call( + RpcMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams(sessionId = "s1", lastSeen = 1), + timeoutMs = 10_000L, + ) + } + + assertTrue("Server should receive request", requestReceived.await(5, TimeUnit.SECONDS)) + + val pending = getPendingCalls() + assertEquals("Should have exactly 1 pending call while awaiting", 1, pending.size) + + callJob.cancel() + + // Wait briefly for cancellation cleanup to execute + withTimeout(2000) { + while (pending.isNotEmpty()) { + kotlinx.coroutines.delay(20) + } + } + + assertTrue("Pending calls should be empty after caller coroutine is cancelled", pending.isEmpty()) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt new file mode 100644 index 00000000..7f103fc2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -0,0 +1,402 @@ +package com.m57.hermescontrol.data.ws + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.lang.reflect.Modifier + +/** + * Ratchet guard preventing new raw Map-param request/send call sites for contract RPC methods (#1375). + * + * Background: + * Issue #1375 migrates raw `HermesWsClient.request(...)`/`.send(...)`/wrapper call sites that pass hand-built + * `Map` params to typed `HermesWsClient.call(RpcMethods.X, params)`. + * + * This test asserts that the set/multiset of raw call sites in `app/src/main/java` matches [BASELINE]. + * - Adding a new raw call site fails CI: author must migrate to `HermesWsClient.call(RpcMethods.X, params)` or, + * for a not-yet-migrated method, consciously add the entry to [BASELINE]. + * - Migrating a call site to typed `call(...)` makes the baseline entry stale, failing CI until removed. + * + * Scanner limitations / blind spots: + * 1. Indirect wrappers: does not catch wrappers that alias method names through intermediate variables or functions + * (e.g. `val m = WsMethods.FOO; send(m)` or helper functions accepting dynamic strings). + * 2. Raw string literals: does not catch direct string literals bypassing `WsMethods` (e.g. `send("session.list")`). + * 3. Dynamic dispatch: reflection or computed method identifiers are invisible to the static regex scanner. + * 4. Custom parameter maps wrapped in separate helper classes or extensions not calling standard request/send. + */ +class RawRpcParamsRatchetTest { + private val wsMethodConstants: Map = + WsMethods::class.java.declaredFields + .filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java } + .associate { it.name to it.get(null) as String } + + @Test + fun rawRpcCallSitesMatchBaselineRatchet() { + val rootDir = resolveMainJavaRoot() + val contractMethodNames = GatewayContract.methods + val contractConstantNames = + wsMethodConstants + .filter { (_, methodStr) -> methodStr in contractMethodNames } + .keys + + val scannedSites = mutableListOf() + rootDir + .walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .filter { !it.path.contains("data/ws/contract") && it.name != "WsMethods.kt" } + .forEach { file -> + val relativePath = file.relativeTo(rootDir).invariantSeparatorsPath + val code = stripComments(file.readText()) + val sites = scanRawRpcCalls(code, contractConstantNames) + for (site in sites) { + scannedSites.add("$relativePath::$site") + } + } + + scannedSites.sort() + + val staleEntries = BASELINE.filter { it !in scannedSites } + val newEntries = scannedSites.filter { it !in BASELINE } + + val failureMessage = + buildString { + if (newEntries.isNotEmpty()) { + appendLine( + "raw call added: migrate to HermesWsClient.call(RpcMethods...) or, for a not-yet-migrated method, add to BASELINE:", + ) + newEntries.forEach { appendLine(" + $it") } + } + if (staleEntries.isNotEmpty()) { + appendLine("stale baseline entry: remove it (call site was migrated or removed):") + staleEntries.forEach { appendLine(" - $it") } + } + } + + assertEquals( + failureMessage.trimEnd(), + BASELINE, + scannedSites, + ) + } + + @Test + fun scannerSelfTestDetectsRawCallsAndIgnoresSafeUsages() { + val contractConstants = setOf("SESSION_LIST", "SESSION_CREATE", "PROMPT_SUBMIT") + + val sampleCode = + """ + // Commented call should be ignored: + // wsClient.request(WsMethods.SESSION_LIST, emptyMap()) + /* Multi-line comment: + HermesWsClient.send(WsMethods.SESSION_LIST) + */ + + // When branch routing should be ignored: + when (method) { + WsMethods.SESSION_LIST -> handleList() + WsMethods.SESSION_CREATE -> handleCreate() + } + + // Comparison checks should be ignored: + if (method == WsMethods.SESSION_LIST) return + if (method != WsMethods.PROMPT_SUBMIT) return + + // Tracking helpers should be ignored: + trackRequest(id, WsMethods.SESSION_LIST) + trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) + + // Positional single-line call: + HermesWsClient.request(WsMethods.SESSION_LIST, emptyMap()) + + // Named argument call across lines: + request( + method = WsMethods.SESSION_CREATE, + params = mapOf("profile" to "test"), + ) + + // Positional multi-line call: + wsClient.send( + WsMethods.PROMPT_SUBMIT, + mapOf("text" to "hello"), + ) + """.trimIndent() + + val cleaned = stripComments(sampleCode) + val detected = scanRawRpcCalls(cleaned, contractConstants) + + assertEquals( + listOf("SESSION_LIST", "SESSION_CREATE", "PROMPT_SUBMIT"), + detected, + ) + } + + companion object { + internal fun resolveMainJavaRoot(): File { + val userDir = File(System.getProperty("user.dir") ?: ".") + val candidates = + listOf( + File(userDir, "app/src/main/java/com/m57/hermescontrol"), + File(userDir, "src/main/java/com/m57/hermescontrol"), + File(userDir, "../app/src/main/java/com/m57/hermescontrol"), + ) + return candidates.firstOrNull { it.isDirectory } + ?: error("Could not resolve app/src/main/java/com/m57/hermescontrol from $userDir") + } + + internal fun stripComments(text: String): String { + val result = StringBuilder(text.length) + var i = 0 + val n = text.length + var inString = false + var inTripleString = false + + while (i < n) { + if (!inString && !inTripleString) { + if (text.startsWith("\"\"\"", i)) { + inTripleString = true + result.append("\"\"\"") + i += 3 + } else if (text[i] == '"') { + inString = true + result.append('"') + i++ + } else if (text.startsWith("//", i)) { + var j = text.indexOf('\n', i) + if (j == -1) j = n + while (i < j) { + result.append(' ') + i++ + } + } else if (text.startsWith("/*", i)) { + var depth = 1 + var j = i + 2 + while (j < n && depth > 0) { + if (text.startsWith("/*", j)) { + depth++ + j += 2 + } else if (text.startsWith("*/", j)) { + depth-- + j += 2 + } else { + j++ + } + } + while (i < j) { + result.append(if (text[i] == '\n') '\n' else ' ') + i++ + } + } else { + result.append(text[i]) + i++ + } + } else if (inTripleString) { + if (text.startsWith("\"\"\"", i)) { + inTripleString = false + result.append("\"\"\"") + i += 3 + } else { + result.append(text[i]) + i++ + } + } else { + if (text[i] == '\\' && i + 1 < n) { + result.append(text[i]).append(text[i + 1]) + i += 2 + } else if (text[i] == '"') { + inString = false + result.append('"') + i++ + } else { + result.append(text[i]) + i++ + } + } + } + return result.toString() + } + + internal fun scanRawRpcCalls( + cleanCode: String, + contractConstants: Set, + ): List { + val regex = Regex("""WsMethods\.([A-Za-z0-9_]+)""") + val results = mutableListOf() + + for (match in regex.findAll(cleanCode)) { + val constantName = match.groupValues[1] + if (constantName !in contractConstants) continue + + val startIndex = match.range.first + if (isMethodArgumentOfRpcCall(cleanCode, startIndex)) { + results.add(constantName) + } + } + return results + } + + private fun isMethodArgumentOfRpcCall( + cleanCode: String, + matchStart: Int, + ): Boolean { + var i = matchStart - 1 + while (i >= 0 && cleanCode[i].isWhitespace()) { + i-- + } + + var isNamedMethodParam = false + if (i >= 0 && cleanCode[i] == '=') { + var j = i - 1 + while (j >= 0 && cleanCode[j].isWhitespace()) { + j-- + } + val identEnd = j + 1 + while (j >= 0 && cleanCode[j].isLetterOrDigit()) { + j-- + } + val paramName = cleanCode.substring(j + 1, identEnd) + if (paramName == "method") { + isNamedMethodParam = true + i = j + while (i >= 0 && cleanCode[i].isWhitespace()) { + i-- + } + } else { + return false + } + } + + if (i < 0) return false + val delimiter = cleanCode[i] + if (delimiter != '(' && delimiter != ',') return false + if (delimiter == ',' && !isNamedMethodParam) { + // Positional method argument in Kotlin RPC calls is the first argument directly following '(' + return false + } + + var parenDepth = 0 + var k = i + while (k >= 0) { + val c = cleanCode[k] + if (c == ')') { + parenDepth++ + } else if (c == '(') { + if (parenDepth > 0) { + parenDepth-- + } else { + var m = k - 1 + while (m >= 0 && cleanCode[m].isWhitespace()) { + m-- + } + val endFn = m + 1 + while (m >= 0 && + (cleanCode[m].isLetterOrDigit() || cleanCode[m] == '.' || cleanCode[m] == '_') + ) { + m-- + } + val fnName = cleanCode.substring(m + 1, endFn) + val lowerFn = fnName.lowercase() + if (fnName in setOf("if", "while", "for", "when")) return false + if (lowerFn.contains("track")) return false + return true + } + } else if (c == '{' || c == '}' || c == ';') { + return false + } + k-- + } + return false + } + + /** + * Multiset of remaining unmigrated raw RPC call sites in `app/src/main/java/com/m57/hermescontrol/`. + * Excludes `data/ws/HermesWsClient.kt::SESSION_EVENTS_SINCE` which is migrated to typed `RpcMethods`. + * Notice HermesWsClient.kt retains its other raw uses: `CLIENT_CAPABILITIES`, `PROMPT_SUBMIT`, + * and `SESSION_REDIRECT`. + */ + val BASELINE: List = + listOf( + "data/ws/AccountConnectorRepository.kt::CONNECTORS_ACCOUNTS", + "data/ws/AccountConnectorRepository.kt::CONNECTORS_ACCOUNTS_REMOVE", + "data/ws/AccountConnectorRepository.kt::CONNECTORS_CATALOG", + "data/ws/AccountConnectorRepository.kt::CONNECTORS_CONNECT", + "data/ws/AccountConnectorRepository.kt::CONNECTORS_LIST", + "data/ws/AccountConnectorRepository.kt::CONNECTORS_OPERATION_STATUS", + "data/ws/AccountConnectorRepository.kt::CONNECTORS_POLICY_GET", + "data/ws/AccountConnectorRepository.kt::CONNECTORS_POLICY_SET", + "data/ws/AccountConnectorRepository.kt::CONNECTORS_TOOLS", + "data/ws/BillingRepository.kt::SUBSCRIPTION_CHANGE", + "data/ws/BillingRepository.kt::SUBSCRIPTION_PREVIEW", + "data/ws/BillingRepository.kt::SUBSCRIPTION_RESUME", + "data/ws/BillingRepository.kt::SUBSCRIPTION_STATE", + "data/ws/BillingRepository.kt::SUBSCRIPTION_UPGRADE", + "data/ws/BillingRepository.kt::USAGE_BARS", + "data/ws/ConnectorRepository.kt::CONNECTORS_CONNECT", + "data/ws/ConnectorRepository.kt::CONNECTORS_LIST", + "data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES", + "data/ws/HermesWsClient.kt::PROMPT_SUBMIT", + "data/ws/HermesWsClient.kt::SESSION_REDIRECT", + "data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS", + "data/ws/PluginManageRepository.kt::PLUGINS_MANAGE", + "data/ws/PluginManageRepository.kt::PLUGINS_MANAGE", + "data/ws/PluginRemovalRepository.kt::PLUGINS_MANAGE", + "data/ws/ProjectsSource.kt::PROJECTS_LIST", + "data/ws/SessionLiveStatusSource.kt::SESSION_ACTIVE_LIST", + "data/ws/SubagentRepository.kt::SUBAGENT_LIST", + "data/ws/SubagentRepository.kt::SUBAGENT_TAIL", + "data/ws/VaultRepository.kt::VAULT_LIST", + "data/ws/VaultRepository.kt::VAULT_LOCK", + "data/ws/VaultRepository.kt::VAULT_SOURCES", + "data/ws/VaultRepository.kt::VAULT_SOURCE_SET", + "data/ws/VaultRepository.kt::VAULT_UNLOCK", + "notification/NotificationReplyReceiver.kt::PROMPT_SUBMIT", + "notification/NotificationReplyReceiver.kt::SESSION_RESUME", + "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", + "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", + "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", + "ui/bots/BotsViewModel.kt::PROFILES_LIST", + "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", + "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", + "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", + "ui/bots/group/GroupChatViewModel.kt::PROFILES_LIST", + "ui/bots/group/GroupChatViewModel.kt::PROMPT_SUBMIT", + "ui/bots/group/GroupChatViewModel.kt::PROMPT_SUBMIT", + "ui/bots/group/GroupChatViewModel.kt::SESSION_CREATE", + "ui/bots/group/GroupChatViewModel.kt::SESSION_INTERRUPT", + "ui/chat/ChatApprovalsDelegate.kt::APPROVAL_PENDING", + "ui/chat/ChatApprovalsDelegate.kt::APPROVAL_RECEIVED", + "ui/chat/ChatApprovalsDelegate.kt::APPROVAL_RESPOND", + "ui/chat/ChatConnectionOperationDelegate.kt::CONNECTION_RESPOND", + "ui/chat/ChatConnectionOperationDelegate.kt::CONNECTION_RESPOND", + "ui/chat/ChatConnectionOperationDelegate.kt::CONNECTORS_OPERATION_WAKE", + "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", + "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", + "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", + "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", + "ui/chat/ChatViewModel.kt::COMMANDS_CATALOG", + "ui/chat/ChatViewModel.kt::COMMAND_DISPATCH", + "ui/chat/ChatViewModel.kt::COMMAND_DISPATCH", + "ui/chat/ChatViewModel.kt::CONFIG_GET", + "ui/chat/ChatViewModel.kt::CONFIG_SET", + "ui/chat/ChatViewModel.kt::FILE_ATTACH", + "ui/chat/ChatViewModel.kt::IMAGE_ATTACH_BYTES", + "ui/chat/ChatViewModel.kt::PROMPT_BTW", + "ui/chat/ChatViewModel.kt::SESSION_BRANCH", + "ui/chat/ChatViewModel.kt::SESSION_BRANCH_WHOLE", + "ui/chat/ChatViewModel.kt::SESSION_COMPRESS", + "ui/chat/ChatViewModel.kt::SESSION_CONTEXT_BREAKDOWN", + "ui/chat/ChatViewModel.kt::SESSION_CREATE", + "ui/chat/ChatViewModel.kt::SESSION_INTERRUPT", + "ui/chat/ChatViewModel.kt::SESSION_INTERRUPT", + "ui/chat/ChatViewModel.kt::SESSION_INTERRUPT", + "ui/chat/ChatViewModel.kt::SESSION_INTERRUPT", + "ui/chat/ChatViewModel.kt::SESSION_LIST", + "ui/chat/ChatViewModel.kt::SESSION_RESUME", + "ui/chat/ChatViewModel.kt::SESSION_STEER", + "ui/chat/ChatViewModel.kt::SESSION_USAGE", + "ui/chat/ChatViewModel.kt::SLASH_EXEC", + "ui/process/ProcessesViewModel.kt::PROCESS_KILL", + "ui/process/ProcessesViewModel.kt::PROCESS_LIST", + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt new file mode 100644 index 00000000..eb4468db --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt @@ -0,0 +1,106 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.ws.GatewayContract +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ParamsDescriptorCheckTest { + @Serializable + private data class ValidParams( + @SerialName("session_id") val sessionId: String, + @SerialName("last_seen") val lastSeen: Int? = null, + ) + + @Serializable + private data class ExtraFieldParams( + @SerialName("session_id") val sessionId: String, + @SerialName("since_seq") val sinceSeq: Int? = null, + ) + + @Serializable + private data class MissingRequiredParams( + @SerialName("last_seen") val lastSeen: Int? = null, + ) + + @Serializable + private data class DefaultOnRequiredParams( + @SerialName("session_id") val sessionId: String = "default_id", + ) + + @Serializable + private data class NullableRequiredParams( + @SerialName("session_id") val sessionId: String?, + ) + + @Serializable + private data class DeliverMissingProfileParams( + val message: String, + ) + + @Test + fun correctClassPasses() { + val problems = + GatewayContract.paramsDescriptorProblems( + "session.events.since", + ValidParams.serializer().descriptor, + ) + assertTrue(problems.joinToString("\n"), problems.isEmpty()) + } + + @Test + fun extraFieldIsRejected() { + val problems = + GatewayContract.paramsDescriptorProblems( + "session.events.since", + ExtraFieldParams.serializer().descriptor, + ) + assertEquals(1, problems.count { "unknown param 'since_seq'" in it }) + } + + @Test + fun missingRequiredFieldIsRejected() { + val problems = + GatewayContract.paramsDescriptorProblems( + "session.events.since", + MissingRequiredParams.serializer().descriptor, + ) + assertEquals(1, problems.count { "missing required param 'session_id'" in it }) + } + + @Test + fun requiredFieldWithDefaultValueIsRejected() { + val problems = + GatewayContract.paramsDescriptorProblems( + "session.events.since", + DefaultOnRequiredParams.serializer().descriptor, + ) + assertEquals(1, problems.count { "required param 'session_id' must not be optional" in it }) + } + + @Test + fun requiredFieldTypedNullableIsRejected() { + val problems = + GatewayContract.paramsDescriptorProblems( + "session.events.since", + NullableRequiredParams.serializer().descriptor, + ) + assertEquals( + 1, + problems.count { "required param 'session_id' must not be nullable unless schema allows null" in it }, + ) + } + + @Test + fun omittingProfilePassesEvenWhenRequiredBySchema() { + // bot_relay.deliver has required: ["profile", "message"] + val problems = + GatewayContract.paramsDescriptorProblems( + "bot_relay.deliver", + DeliverMissingProfileParams.serializer().descriptor, + ) + assertTrue(problems.joinToString("\n"), problems.isEmpty()) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSinceTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSinceTypesTest.kt new file mode 100644 index 00000000..d4b4b76b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSinceTypesTest.kt @@ -0,0 +1,126 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionEventsSinceTypesTest { + @Test + fun paramsWithLastSeenEncodesToExpectedJsonObject() { + val params = SessionEventsSinceParams(sessionId = "s1", lastSeen = 7) + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionEventsSinceParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s1") + put("last_seen", 7) + } + + assertEquals(expected, encoded) + } + + @Test + fun paramsWithNullLastSeenEncodesToSessionIdOnly() { + val params = SessionEventsSinceParams(sessionId = "s1", lastSeen = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionEventsSinceParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun resultDecodesFullRealisticPayloadIgnoringUnknownKeys() { + val payload = + buildJsonObject { + put( + "events", + kotlinx.serialization.json.buildJsonArray { + add( + buildJsonObject { + put("type", "message.token") + put("seq", 1) + put("session_id", "s1") + }, + ) + }, + ) + put("latest_seq", 42) + put("truncated", false) + put("epoch", "ep-999") + // Extra unknown keys from server that must be ignored + put("count", 1) + put( + "open_requests", + kotlinx.serialization.json.buildJsonArray { + add(JsonPrimitive("req-1")) + }, + ) + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionEventsSinceResult.serializer(), + payload, + ) + + assertEquals("ep-999", decoded.epoch) + assertEquals(42, decoded.latestSeq) + assertEquals(false, decoded.truncated) + assertEquals(1, decoded.events?.size) + } + + @Test + fun resultDecodesEmptyObjectToAllNullDefaults() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionEventsSinceResult.serializer(), + payload, + ) + + assertNull(decoded.events) + assertNull(decoded.latestSeq) + assertNull(decoded.truncated) + assertNull(decoded.epoch) + } + + @Test + fun resultToleratesTruncatedAbsent() { + val payload = + buildJsonObject { + put("latest_seq", 10) + put("epoch", "epoch-1") + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionEventsSinceResult.serializer(), + payload, + ) + + assertNull(decoded.truncated) + assertEquals(10, decoded.latestSeq) + assertEquals("epoch-1", decoded.epoch) + assertNull(decoded.events) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("session.events.since", RpcMethods.SESSION_EVENTS_SINCE.name) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_EVENTS_SINCE)) + } +} From a780df32617a899b1a58d2b8452ddf1718c3fd4e Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:31:03 +0300 Subject: [PATCH 066/107] refactor(#1375): typed params for session.create, session.resume, prompt.submit (#1381) --- .../hermescontrol/data/ws/HermesWsClient.kt | 62 ++- .../data/ws/contract/RpcMethod.kt | 29 +- .../data/ws/contract/SessionPrompt.kt | 43 ++ .../notification/NotificationReplyReceiver.kt | 40 +- .../ui/bots/group/GroupChatViewModel.kt | 65 ++- .../data/ws/HermesWsClientTypedSendTest.kt | 484 ++++++++++++++++++ .../data/ws/RawRpcParamsRatchetTest.kt | 6 - .../ws/contract/SessionPromptTypesTest.kt | 290 +++++++++++ .../NotificationReplyReceiverTest.kt | 13 +- 9 files changed, 942 insertions(+), 90 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionPrompt.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedSendTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionPromptTypesTest.kt 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 acc2c633..21ebec99 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 @@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.await import com.m57.hermescontrol.data.session.ActiveSessionHolder +import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams import com.m57.hermescontrol.data.ws.contract.RpcMethod import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams @@ -827,8 +828,10 @@ object HermesWsClient { /** * Send a typed JSON-RPC request and await its deserialized result. * - * Params classes are strict writers, result classes are tolerant readers, and - * profile is never a field in params (it is injected by WsProfileParams). + * Params classes are strict writers; `profile` is a field ONLY on methods + * where callers pass an explicit override (e.g. session.create, session.resume); + * otherwise it is injected by WsProfileParams (an explicit value wins). + * Result classes are tolerant readers. */ suspend fun call( method: RpcMethod, @@ -836,10 +839,7 @@ object HermesWsClient { timeoutMs: Long = REQUEST_TIMEOUT_MS, suppressErrorEvent: Boolean = false, ): R { - val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params) - require(encoded is JsonObject) { - "RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}" - } + val encoded = encodeParams(method, params) val deferred = request(method.name, encoded, timeoutMs, suppressErrorEvent) val result = try { @@ -852,6 +852,17 @@ object HermesWsClient { return OkHttpProvider.json.decodeFromJsonElement(method.result, element) } + private fun encodeParams( + method: RpcMethod, + params: P, + ): JsonObject { + val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params) + require(encoded is JsonObject) { + "RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}" + } + return encoded + } + /** Complete (or fail) a single pending call and cancel its timer. */ private fun resolvePending( id: String, @@ -957,6 +968,21 @@ object HermesWsClient { onSent: ((String) -> Unit)? = null, ): String = send(method, params, onSent, true) + /** + * Send a typed fire-and-forget JSON-RPC request. + * + * Params classes are strict writers; `profile` is a field ONLY on methods + * where callers pass an explicit override (session.create, session.resume); + * otherwise it is injected by WsProfileParams (an explicit value wins). + * + * @return the request id used (can be matched against [WsEvent.RpcResult]). + */ + fun send( + method: RpcMethod, + params: P, + onSent: ((String) -> Unit)? = null, + ): String = send(method.name, encodeParams(method, params), onSent) + private fun send( method: String, params: Map, @@ -1065,23 +1091,17 @@ object HermesWsClient { text: String, onSent: ((String) -> Unit)? = null, queued: Boolean = false, - ): String { - val params = - buildMap { - put("session_id", sessionId) - put("text", text) - // Explicit queue semantics: the gateway's busy-input policy - // forces "run after, never interrupt" when prompt.submit - // carries queued=true (hermes-agent methods_prompt.py - // _handle_busy_submit) — used by /queue. - if (queued) put("queued", true) - } - return send( - method = WsMethods.PROMPT_SUBMIT, - params = params, + ): String = + send( + method = RpcMethods.PROMPT_SUBMIT, + params = + PromptSubmitParams( + sessionId = sessionId, + text = text, + queued = queued.takeIf { it }, + ), onSent = onSent, ) - } /** * Convenience: redirect the active model turn while it is still generating diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index 4288387d..ef0a134b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -17,6 +17,33 @@ object RpcMethods { SessionEventsSinceResult.serializer(), ) + val SESSION_CREATE: RpcMethod = + RpcMethod( + WsMethods.SESSION_CREATE, + SessionCreateParams.serializer(), + SessionCreateResult.serializer(), + ) + + val SESSION_RESUME: RpcMethod = + RpcMethod( + WsMethods.SESSION_RESUME, + SessionResumeParams.serializer(), + SessionResumeResult.serializer(), + ) + + val PROMPT_SUBMIT: RpcMethod = + RpcMethod( + WsMethods.PROMPT_SUBMIT, + PromptSubmitParams.serializer(), + PromptSubmitResult.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ - val all: List> = listOf(SESSION_EVENTS_SINCE) + val all: List> = + listOf( + SESSION_EVENTS_SINCE, + SESSION_CREATE, + SESSION_RESUME, + PROMPT_SUBMIT, + ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionPrompt.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionPrompt.kt new file mode 100644 index 00000000..42dcbd03 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionPrompt.kt @@ -0,0 +1,43 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SessionCreateParams( + @SerialName("source") val source: String? = null, + @SerialName("profile") val profile: String? = null, + @SerialName("title") val title: String? = null, + @SerialName("hidden") val hidden: Boolean? = null, +) + +@Serializable +data class SessionResumeParams( + @SerialName("session_id") val sessionId: String, + @SerialName("omit_messages") val omitMessages: Boolean? = null, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class PromptSubmitParams( + @SerialName("session_id") val sessionId: String, + @SerialName("text") val text: String, + @SerialName("queued") val queued: Boolean? = null, +) + +// tolerant readers: all nullable with null defaults, only fields callers read +@Serializable +data class SessionCreateResult( + @SerialName("session_id") val sessionId: String? = null, + @SerialName("stored_session_id") val storedSessionId: String? = null, +) + +@Serializable +data class SessionResumeResult( + @SerialName("session_id") val sessionId: String? = null, +) + +@Serializable +data class PromptSubmitResult( + val status: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt index 42867446..c72a073e 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt @@ -11,7 +11,9 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionResumeParams import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -109,12 +111,11 @@ open class NotificationReplyReceiver : BroadcastReceiver() { val runtimeSessionId = ActiveSessionHolder.resolveRuntimeSessionId(sessionId) ?: resumeSession(sessionId) - HermesWsClient - .request( - WsMethods.PROMPT_SUBMIT, - mapOf("session_id" to runtimeSessionId, "text" to replyText), - timeoutMs = REPLY_TIMEOUT_MS, - ).await() + HermesWsClient.call( + method = RpcMethods.PROMPT_SUBMIT, + params = PromptSubmitParams(sessionId = runtimeSessionId, text = replyText), + timeoutMs = REPLY_TIMEOUT_MS, + ) val entity = com.m57.hermescontrol.data.local.ChatMessageEntity( @@ -152,24 +153,21 @@ open class NotificationReplyReceiver : BroadcastReceiver() { } private suspend fun resumeSession(storedSessionId: String): String { - val profile = AuthManager.activeProfileId.value + val profile = AuthManager.activeProfileId.value?.takeIf { it.isNotBlank() } val params = - mutableMapOf( - "session_id" to storedSessionId, - "omit_messages" to true, + SessionResumeParams( + sessionId = storedSessionId, + omitMessages = true, + profile = profile, ) - if (!profile.isNullOrBlank()) { - params["profile"] = profile - } val result = - HermesWsClient - .request( - WsMethods.SESSION_RESUME, - params, - timeoutMs = REPLY_TIMEOUT_MS, - ).await() as? Map<*, *> + HermesWsClient.call( + method = RpcMethods.SESSION_RESUME, + params = params, + timeoutMs = REPLY_TIMEOUT_MS, + ) val runtimeSessionId = - (result?.get("session_id") as? String)?.takeIf { it.isNotBlank() } + result.sessionId?.takeIf { it.isNotBlank() } ?: error("Resume returned no runtime session id") ActiveSessionHolder.set(runtimeSessionId, storedSessionId) return runtimeSessionId diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index 8d6e7fd5..6cc4ba96 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -17,6 +17,9 @@ 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.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionCreateParams import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.ui.chat.tool.ToolResultSummary import kotlinx.coroutines.CompletableDeferred @@ -559,17 +562,24 @@ class GroupChatViewModel( val title = "Group: $groupName" try { - val createParams = - buildMap { - put("profile", bot.name) - put("title", title) - put("source", "desktop") - put("hidden", true) - } - val deferred = HermesWsClient.request(WsMethods.SESSION_CREATE, createParams) - val res = deferred.await() - val sessionInfo = extractSessionInfo(res) - if (sessionInfo != null) { + val res = + HermesWsClient.call( + method = RpcMethods.SESSION_CREATE, + params = + SessionCreateParams( + source = "desktop", + profile = bot.name, + title = title, + hidden = true, + ), + ) + val runtimeId = res.sessionId?.takeIf { it.isNotBlank() } + if (runtimeId != null) { + val sessionInfo = + MemberSession( + runtimeSessionId = runtimeId, + storedSessionId = res.storedSessionId, + ) memberSessions[bot.name] = sessionInfo return sessionInfo } @@ -701,11 +711,10 @@ class GroupChatViewModel( // turn boundary can be proven for them. They stay uncorrelated // (reply notifications from group turns are never auto-dismissed // from REST hydration) instead of borrowing another turn's bound. - HermesWsClient - .request( - WsMethods.PROMPT_SUBMIT, - mapOf("session_id" to runtimeId, "text" to prompt), - ).await() + HermesWsClient.call( + method = RpcMethods.PROMPT_SUBMIT, + params = PromptSubmitParams(sessionId = runtimeId, text = prompt), + ) } catch (submitErr: Exception) { Log.w( "GroupChatViewModel", @@ -735,11 +744,10 @@ class GroupChatViewModel( storedId?.let { inFlightTurns[it] = turnDeferred } try { - HermesWsClient - .request( - WsMethods.PROMPT_SUBMIT, - mapOf("session_id" to runtimeId, "text" to prompt), - ).await() + HermesWsClient.call( + method = RpcMethods.PROMPT_SUBMIT, + params = PromptSubmitParams(sessionId = runtimeId, text = prompt), + ) } catch (retryErr: Exception) { Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}") return null @@ -1202,19 +1210,4 @@ class GroupChatViewModel( val clean = text.trim().lowercase() return clean == "(pass)" || clean == "pass" || clean == "(pass)." || clean == "pass." } - - private fun extractSessionInfo(response: Any?): MemberSession? { - if (response is Map<*, *>) { - val runtimeId = - response["session_id"]?.toString()?.trim('\"', ' ') - ?: (response["result"] as? Map<*, *>)?.get("session_id")?.toString()?.trim('\"', ' ') - val storedId = - response["stored_session_id"]?.toString()?.trim('\"', ' ') - ?: (response["result"] as? Map<*, *>)?.get("stored_session_id")?.toString()?.trim('\"', ' ') - if (!runtimeId.isNullOrBlank()) { - return MemberSession(runtimeSessionId = runtimeId, storedSessionId = storedId) - } - } - return null - } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedSendTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedSendTest.kt new file mode 100644 index 00000000..655e174d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedSendTest.kt @@ -0,0 +1,484 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionCreateParams +import com.m57.hermescontrol.data.ws.contract.SessionResumeParams +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientTypedSendTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun typedSendSessionResumePutsExpectedFrameAndInvokesOnSent() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.resume") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.send( + method = RpcMethods.SESSION_RESUME, + params = SessionResumeParams(sessionId = "sess-1", omitMessages = true), + onSent = { id -> onSentId = id }, + ) + + assertTrue(returnedId.isNotBlank()) + assertEquals(returnedId, onSentId) + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("session.resume", (frame["method"] as? JsonPrimitive)?.content) + assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-1") + put("omit_messages", true) + } + assertEquals(expectedParams, params) + } + + @Test + fun typedSendWithActiveProfileInjectsProfileIntoSessionResume() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.resume") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + AuthManager.setActiveProfileId("active-profile-1") + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + HermesWsClient.send( + method = RpcMethods.SESSION_RESUME, + params = SessionResumeParams(sessionId = "sess-active", omitMessages = true), + ) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-active") + put("omit_messages", true) + put("profile", "active-profile-1") + } + assertEquals(expectedParams, params) + assertEquals("active-profile-1", (params!!["profile"] as? JsonPrimitive)?.content) + } + + @Test + fun typedSendExplicitProfileWinsOverActiveProfileForSessionResume() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.resume") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + AuthManager.setActiveProfileId("active-profile-1") + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + HermesWsClient.send( + method = RpcMethods.SESSION_RESUME, + params = + SessionResumeParams( + sessionId = "sess-active", + omitMessages = true, + profile = "explicit-resume-profile", + ), + ) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-active") + put("omit_messages", true) + put("profile", "explicit-resume-profile") + } + assertEquals(expectedParams, params) + assertEquals("explicit-resume-profile", (params!!["profile"] as? JsonPrimitive)?.content) + } + + @Test + fun sessionCreateWithExplicitProfileKeepsExplicitWhenDifferentProfileActive() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.create") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + AuthManager.setActiveProfileId("active-profile-2") + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + HermesWsClient.send( + method = RpcMethods.SESSION_CREATE, + params = SessionCreateParams(profile = "bot-1"), + ) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("profile", "bot-1") + } + assertEquals(expectedParams, params) + assertEquals("bot-1", (params!!["profile"] as? JsonPrimitive)?.content) + } + + @Test + fun sendMessagePutsSessionIdAndTextOnTheWire() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "prompt.submit") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.sendMessage( + sessionId = "sess-msg-1", + text = "plain message", + onSent = { id -> onSentId = id }, + ) + + assertTrue(returnedId.isNotBlank()) + assertEquals(returnedId, onSentId) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-msg-1") + put("text", "plain message") + } + assertEquals(expectedParams, params) + assertFalse(params!!.containsKey("queued")) + } + + @Test + fun sendMessageWithQueuedTrueAddsQueuedTrue() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "prompt.submit") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + HermesWsClient.sendMessage( + sessionId = "sess-msg-2", + text = "queued message", + queued = true, + ) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-msg-2") + put("text", "queued message") + put("queued", true) + } + assertEquals(expectedParams, params) + val queuedElement = params!!["queued"] as? JsonPrimitive + assertTrue(queuedElement != null && queuedElement.isString.not()) + assertEquals("true", queuedElement!!.content) + } + + @Test + fun sendMessageWithQueuedFalseHasNoQueuedKey() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "prompt.submit") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + HermesWsClient.sendMessage( + sessionId = "sess-msg-3", + text = "un为其queued message", + queued = false, + ) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-msg-3") + put("text", "un为其queued message") + } + assertEquals(expectedParams, params) + assertFalse(params!!.containsKey("queued")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index 7f103fc2..5751feef 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -334,7 +334,6 @@ class RawRpcParamsRatchetTest { "data/ws/ConnectorRepository.kt::CONNECTORS_CONNECT", "data/ws/ConnectorRepository.kt::CONNECTORS_LIST", "data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES", - "data/ws/HermesWsClient.kt::PROMPT_SUBMIT", "data/ws/HermesWsClient.kt::SESSION_REDIRECT", "data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS", "data/ws/PluginManageRepository.kt::PLUGINS_MANAGE", @@ -349,8 +348,6 @@ class RawRpcParamsRatchetTest { "data/ws/VaultRepository.kt::VAULT_SOURCES", "data/ws/VaultRepository.kt::VAULT_SOURCE_SET", "data/ws/VaultRepository.kt::VAULT_UNLOCK", - "notification/NotificationReplyReceiver.kt::PROMPT_SUBMIT", - "notification/NotificationReplyReceiver.kt::SESSION_RESUME", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", @@ -359,9 +356,6 @@ class RawRpcParamsRatchetTest { "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", "ui/bots/group/GroupChatViewModel.kt::PROFILES_LIST", - "ui/bots/group/GroupChatViewModel.kt::PROMPT_SUBMIT", - "ui/bots/group/GroupChatViewModel.kt::PROMPT_SUBMIT", - "ui/bots/group/GroupChatViewModel.kt::SESSION_CREATE", "ui/bots/group/GroupChatViewModel.kt::SESSION_INTERRUPT", "ui/chat/ChatApprovalsDelegate.kt::APPROVAL_PENDING", "ui/chat/ChatApprovalsDelegate.kt::APPROVAL_RECEIVED", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionPromptTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionPromptTypesTest.kt new file mode 100644 index 00000000..bf5c9c44 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionPromptTypesTest.kt @@ -0,0 +1,290 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionPromptTypesTest { + @Test + fun createWithSourceOnlyEncodesExactlySource() { + val params = SessionCreateParams(source = "cli") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params) + + val expected = + buildJsonObject { + put("source", "cli") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("source"), (encoded as JsonObject).keys) + } + + @Test + fun groupStyleCreateEncodesExpectedFourKeysWithHiddenBoolean() { + val params = + SessionCreateParams( + profile = "work", + title = "Team sync", + source = "matrix", + hidden = true, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params) + + val expected = + buildJsonObject { + put("source", "matrix") + put("profile", "work") + put("title", "Team sync") + put("hidden", true) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + val hiddenElement = (encoded as JsonObject)["hidden"] as? JsonPrimitive + assertTrue(hiddenElement != null && hiddenElement.isString.not()) + } + + @Test + fun allNullCreateParamsEncodeToEmptyObject() { + val params = SessionCreateParams() + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun createWithExplicitFalseHiddenIsEncoded() { + val params = SessionCreateParams(hidden = false) + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params) + + val expected = + buildJsonObject { + put("hidden", false) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("hidden"), (encoded as JsonObject).keys) + } + + @Test + fun resumeEncodesSessionIdAndOmitMessagesExactly() { + val params = SessionResumeParams(sessionId = "s-123", omitMessages = true) + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s-123") + put("omit_messages", true) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "omit_messages"), (encoded as JsonObject).keys) + } + + @Test + fun resumeWithProfileEncodesExpectedThreeKeys() { + val params = + SessionResumeParams( + sessionId = "s-456", + omitMessages = true, + profile = "personal", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s-456") + put("omit_messages", true) + put("profile", "personal") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "omit_messages", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun resumeWithExplicitFalseOmitMessagesIsEncoded() { + val params = SessionResumeParams(sessionId = "s-789", omitMessages = false) + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s-789") + put("omit_messages", false) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "omit_messages"), (encoded as JsonObject).keys) + } + + @Test + fun submitEncodesSessionIdAndText() { + val params = PromptSubmitParams(sessionId = "s-100", text = "hello hermes") + val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s-100") + put("text", "hello hermes") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys) + } + + @Test + fun submitWithQueuedTrueEncodesQueued() { + val params = PromptSubmitParams(sessionId = "s-101", text = "queued prompt", queued = true) + val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s-101") + put("text", "queued prompt") + put("queued", true) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "text", "queued"), (encoded as JsonObject).keys) + } + + @Test + fun submitWithExplicitFalseQueuedIsEncoded() { + val params = PromptSubmitParams(sessionId = "s-102", text = "direct prompt", queued = false) + val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s-102") + put("text", "direct prompt") + put("queued", false) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "text", "queued"), (encoded as JsonObject).keys) + } + + @Test + fun sessionCreateResultDecodesFullPayloadAndIgnoresUnknownKeys() { + val payload = + buildJsonObject { + put("session_id", "sess-created-1") + put("stored_session_id", "stored-1") + put("created_at", 123456789) + put("unknown_flag", true) + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionCreateResult.serializer(), + payload, + ) + + assertEquals("sess-created-1", decoded.sessionId) + assertEquals("stored-1", decoded.storedSessionId) + } + + @Test + fun sessionCreateResultDecodesEmptyObjectToNulls() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionCreateResult.serializer(), + payload, + ) + + assertNull(decoded.sessionId) + assertNull(decoded.storedSessionId) + } + + @Test + fun sessionResumeResultDecodesFullPayloadAndIgnoresUnknownKeys() { + val payload = + buildJsonObject { + put("session_id", "sess-resumed-1") + put("active_model", "gemini") + put("unread_count", 0) + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionResumeResult.serializer(), + payload, + ) + + assertEquals("sess-resumed-1", decoded.sessionId) + } + + @Test + fun sessionResumeResultDecodesEmptyObjectToNulls() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionResumeResult.serializer(), + payload, + ) + + assertNull(decoded.sessionId) + } + + @Test + fun promptSubmitResultDecodesFullPayloadAndIgnoresUnknownKeys() { + val payload = + buildJsonObject { + put("status", "accepted") + put("ticket_id", "tick-42") + put("queued_at", "2026-09-29T10:00:00Z") + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + PromptSubmitResult.serializer(), + payload, + ) + + assertEquals("accepted", decoded.status) + } + + @Test + fun promptSubmitResultDecodesEmptyObjectToNulls() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + PromptSubmitResult.serializer(), + payload, + ) + + assertNull(decoded.status) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("session.create", RpcMethods.SESSION_CREATE.name) + assertEquals("session.resume", RpcMethods.SESSION_RESUME.name) + assertEquals("prompt.submit", RpcMethods.PROMPT_SUBMIT.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_CREATE)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_RESUME)) + assertTrue(RpcMethods.all.contains(RpcMethods.PROMPT_SUBMIT)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt index fd0953b9..e9b0aaa3 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt @@ -21,6 +21,7 @@ import io.mockk.slot import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.CompletableDeferred +import kotlinx.serialization.json.JsonPrimitive import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -108,7 +109,7 @@ class NotificationReplyReceiverTest { val params = arg>(1) val result = if (method == WsMethods.SESSION_RESUME) { - mapOf("session_id" to "runtime-${params["session_id"]}") + mapOf("session_id" to "runtime-${params.str("session_id")}") } else { mapOf("accepted" to true) } @@ -147,7 +148,7 @@ class NotificationReplyReceiverTest { verify { HermesWsClient.request( WsMethods.PROMPT_SUBMIT, - match { it["session_id"] == "runtime-session-abc" && it["text"] == "Hello" }, + match { it.str("session_id") == "runtime-session-abc" && it.str("text") == "Hello" }, any(), ) } @@ -329,19 +330,19 @@ class NotificationReplyReceiverTest { verify { HermesWsClient.request( WsMethods.SESSION_RESUME, - match { it["session_id"] == "session-abc" && it["omit_messages"] == true }, + match { it.str("session_id") == "session-abc" && it["omit_messages"] == JsonPrimitive(true) }, any(), ) HermesWsClient.request( WsMethods.PROMPT_SUBMIT, - match { it["session_id"] == "runtime-session-abc" }, + match { it.str("session_id") == "runtime-session-abc" }, any(), ) } verify(inverse = true) { HermesWsClient.request( WsMethods.PROMPT_SUBMIT, - match { it["session_id"] == "stale-runtime" }, + match { it.str("session_id") == "stale-runtime" }, any(), ) } @@ -454,3 +455,5 @@ class NotificationReplyReceiverTest { every { mockIntent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) } returns sessionId } } + +private fun Map.str(key: String): String? = (this[key] as? JsonPrimitive)?.content ?: this[key] as? String From aac42032fb59a543f32aefb53c28d9c660d7ac59 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:02:32 +0300 Subject: [PATCH 067/107] refactor(#1375): typed params for session.interrupt, steer, redirect (#1382) --- .../hermescontrol/data/ws/HermesWsClient.kt | 9 +- .../data/ws/contract/RpcMethod.kt | 24 ++ .../data/ws/contract/SessionControl.kt | 26 ++ .../ui/bots/group/GroupChatViewModel.kt | 5 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 23 +- .../ws/HermesWsClientSessionControlTest.kt | 281 ++++++++++++++++++ .../data/ws/RawRpcParamsRatchetTest.kt | 7 - .../ws/contract/SessionControlTypesTest.kt | 119 ++++++++ .../ui/chat/ChatViewModelTest.kt | 8 +- 9 files changed, 480 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionControl.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionControlTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionControlTypesTest.kt 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 21ebec99..a9238ebc 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 @@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams import com.m57.hermescontrol.data.ws.contract.RpcMethod import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -1115,8 +1116,12 @@ object HermesWsClient { onSent: ((String) -> Unit)? = null, ): String = send( - method = WsMethods.SESSION_REDIRECT, - params = mapOf("session_id" to sessionId, "text" to text), + method = RpcMethods.SESSION_REDIRECT, + params = + SessionCorrectionParams( + sessionId = sessionId, + text = text, + ), onSent = onSent, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index ef0a134b..e89ba820 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -38,6 +38,27 @@ object RpcMethods { PromptSubmitResult.serializer(), ) + val SESSION_INTERRUPT: RpcMethod = + RpcMethod( + WsMethods.SESSION_INTERRUPT, + SessionInterruptParams.serializer(), + SessionInterruptResult.serializer(), + ) + + val SESSION_STEER: RpcMethod = + RpcMethod( + WsMethods.SESSION_STEER, + SessionCorrectionParams.serializer(), + SessionCorrectionResult.serializer(), + ) + + val SESSION_REDIRECT: RpcMethod = + RpcMethod( + WsMethods.SESSION_REDIRECT, + SessionCorrectionParams.serializer(), + SessionCorrectionResult.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -45,5 +66,8 @@ object RpcMethods { SESSION_CREATE, SESSION_RESUME, PROMPT_SUBMIT, + SESSION_INTERRUPT, + SESSION_STEER, + SESSION_REDIRECT, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionControl.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionControl.kt new file mode 100644 index 00000000..6091fc1e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionControl.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SessionInterruptParams( + @SerialName("session_id") val sessionId: String, +) + +@Serializable +data class SessionCorrectionParams( + @SerialName("session_id") val sessionId: String, + @SerialName("text") val text: String, +) + +@Serializable +data class SessionInterruptResult( + val status: String? = null, +) + +@Serializable +data class SessionCorrectionResult( + val status: String? = null, + val text: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index 6cc4ba96..ca8b2e5d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -20,6 +20,7 @@ import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.data.ws.contract.SessionCreateParams +import com.m57.hermescontrol.data.ws.contract.SessionInterruptParams import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.ui.chat.tool.ToolResultSummary import kotlinx.coroutines.CompletableDeferred @@ -909,8 +910,8 @@ class GroupChatViewModel( for (sid in activeSessions.distinct()) { try { HermesWsClient.send( - WsMethods.SESSION_INTERRUPT, - mapOf("session_id" to sid), + RpcMethods.SESSION_INTERRUPT, + SessionInterruptParams(sid), ) } catch (e: Exception) { Log.w("GroupChatViewModel", "Failed to send interrupt for session $sid: ${e.message}") 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 01ba89f1..1f9aac39 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 @@ -37,6 +37,9 @@ 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.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams +import com.m57.hermescontrol.data.ws.contract.SessionInterruptParams import com.m57.hermescontrol.data.ws.toAny import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.notification.ReplyNotificationTracker @@ -2454,8 +2457,8 @@ class ChatViewModel( deleteSnapshotOnRollback = true, ) { wsClient.send( - WsMethods.SESSION_INTERRUPT, - mapOf("session_id" to owner.agentSessionId), + RpcMethods.SESSION_INTERRUPT, + SessionInterruptParams(owner.agentSessionId), onSent = { id -> trackHardInterrupt(id, staged.id, owner, reservation.mainTurnEpoch) }, ) } @@ -2821,8 +2824,8 @@ class ChatViewModel( deleteSnapshotOnRollback = false, ) { wsClient.send( - WsMethods.SESSION_INTERRUPT, - mapOf("session_id" to owner.agentSessionId), + RpcMethods.SESSION_INTERRUPT, + SessionInterruptParams(owner.agentSessionId), onSent = { requestId -> trackHardInterrupt(requestId, id, owner, interruptedTurnEpoch) }, ) } @@ -3043,8 +3046,8 @@ class ChatViewModel( viewModelScope.launch { commitReceiptAndEnqueue(owner, receipt, null, deleteSnapshotOnRollback = false) { wsClient.send( - WsMethods.SESSION_INTERRUPT, - mapOf("session_id" to owner.agentSessionId), + RpcMethods.SESSION_INTERRUPT, + SessionInterruptParams(owner.agentSessionId), onSent = { id -> trackHardInterrupt(id, userMessage.id, owner, interruptedTurnEpoch) }, ) } @@ -3437,8 +3440,8 @@ class ChatViewModel( if (mode == BusySendMode.GUIDE) { enqueueOwned { wsClient.send( - WsMethods.SESSION_STEER, - mapOf("session_id" to owner.agentSessionId, "text" to fullText), + RpcMethods.SESSION_STEER, + SessionCorrectionParams(owner.agentSessionId, fullText), onSent = { id -> trackOutgoingRequest( id, @@ -4328,8 +4331,8 @@ class ChatViewModel( } viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_INTERRUPT, - mapOf("session_id" to sessionId), + RpcMethods.SESSION_INTERRUPT, + SessionInterruptParams(sessionId), onSent = { id -> trackRequest(id, WsMethods.SESSION_INTERRUPT) }, ) } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionControlTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionControlTest.kt new file mode 100644 index 00000000..4d8aeec8 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionControlTest.kt @@ -0,0 +1,281 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams +import com.m57.hermescontrol.data.ws.contract.SessionInterruptParams +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientSessionControlTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun sendRedirectPutsExpectedFrameAndInvokesOnSent() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.redirect") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.sendRedirect( + sessionId = "sess-red-1", + text = "change topic to testing", + onSent = { id -> onSentId = id }, + ) + + assertTrue(returnedId.isNotBlank()) + assertEquals(returnedId, onSentId) + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("session.redirect", (frame["method"] as? JsonPrimitive)?.content) + assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-red-1") + put("text", "change topic to testing") + } + assertEquals(expectedParams, params) + assertEquals(setOf("session_id", "text"), params!!.keys) + } + + @Test + fun typedSendSessionInterruptPutsExpectedFrameAndInvokesOnSent() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.interrupt") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.send( + method = RpcMethods.SESSION_INTERRUPT, + params = SessionInterruptParams(sessionId = "s1"), + onSent = { id -> onSentId = id }, + ) + + assertTrue(returnedId.isNotBlank()) + assertEquals(returnedId, onSentId) + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("session.interrupt", (frame["method"] as? JsonPrimitive)?.content) + assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "s1") + } + assertEquals(expectedParams, params) + assertEquals(setOf("session_id"), params!!.keys) + } + + @Test + fun typedSendSessionSteerPutsExpectedFrameAndInvokesOnSent() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.steer") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.send( + method = RpcMethods.SESSION_STEER, + params = SessionCorrectionParams(sessionId = "s1", text = "steer towards plan B"), + onSent = { id -> onSentId = id }, + ) + + assertTrue(returnedId.isNotBlank()) + assertEquals(returnedId, onSentId) + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("session.steer", (frame["method"] as? JsonPrimitive)?.content) + assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "s1") + put("text", "steer towards plan B") + } + assertEquals(expectedParams, params) + assertEquals(setOf("session_id", "text"), params!!.keys) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index 5751feef..ea8f658d 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -334,7 +334,6 @@ class RawRpcParamsRatchetTest { "data/ws/ConnectorRepository.kt::CONNECTORS_CONNECT", "data/ws/ConnectorRepository.kt::CONNECTORS_LIST", "data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES", - "data/ws/HermesWsClient.kt::SESSION_REDIRECT", "data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS", "data/ws/PluginManageRepository.kt::PLUGINS_MANAGE", "data/ws/PluginManageRepository.kt::PLUGINS_MANAGE", @@ -356,7 +355,6 @@ class RawRpcParamsRatchetTest { "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", "ui/bots/group/GroupChatViewModel.kt::PROFILES_LIST", - "ui/bots/group/GroupChatViewModel.kt::SESSION_INTERRUPT", "ui/chat/ChatApprovalsDelegate.kt::APPROVAL_PENDING", "ui/chat/ChatApprovalsDelegate.kt::APPROVAL_RECEIVED", "ui/chat/ChatApprovalsDelegate.kt::APPROVAL_RESPOND", @@ -380,13 +378,8 @@ class RawRpcParamsRatchetTest { "ui/chat/ChatViewModel.kt::SESSION_COMPRESS", "ui/chat/ChatViewModel.kt::SESSION_CONTEXT_BREAKDOWN", "ui/chat/ChatViewModel.kt::SESSION_CREATE", - "ui/chat/ChatViewModel.kt::SESSION_INTERRUPT", - "ui/chat/ChatViewModel.kt::SESSION_INTERRUPT", - "ui/chat/ChatViewModel.kt::SESSION_INTERRUPT", - "ui/chat/ChatViewModel.kt::SESSION_INTERRUPT", "ui/chat/ChatViewModel.kt::SESSION_LIST", "ui/chat/ChatViewModel.kt::SESSION_RESUME", - "ui/chat/ChatViewModel.kt::SESSION_STEER", "ui/chat/ChatViewModel.kt::SESSION_USAGE", "ui/chat/ChatViewModel.kt::SLASH_EXEC", "ui/process/ProcessesViewModel.kt::PROCESS_KILL", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionControlTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionControlTypesTest.kt new file mode 100644 index 00000000..9ca3cc21 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionControlTypesTest.kt @@ -0,0 +1,119 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionControlTypesTest { + @Test + fun sessionInterruptParamsEncodesSessionIdExactly() { + val params = SessionInterruptParams(sessionId = "sess-int-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionInterruptParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-int-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun sessionCorrectionParamsEncodesSessionIdAndTextExactly() { + val params = SessionCorrectionParams(sessionId = "sess-corr-1", text = "stop that action") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCorrectionParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-corr-1") + put("text", "stop that action") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys) + } + + @Test + fun sessionInterruptResultDecodesFullPayloadAndIgnoresUnknownKeys() { + val payload = + buildJsonObject { + put("status", "interrupted") + put("unknown_key", "ignored_value") + put("extra_flag", true) + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionInterruptResult.serializer(), + payload, + ) + + assertEquals("interrupted", decoded.status) + } + + @Test + fun sessionInterruptResultDecodesEmptyObjectToNulls() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionInterruptResult.serializer(), + payload, + ) + + assertNull(decoded.status) + } + + @Test + fun sessionCorrectionResultDecodesFullPayloadAndIgnoresUnknownKeys() { + val payload = + buildJsonObject { + put("status", "applied") + put("text", "stop that action") + put("ignored_num", 42) + put("nested", buildJsonObject { put("ignored", "yes") }) + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionCorrectionResult.serializer(), + payload, + ) + + assertEquals("applied", decoded.status) + assertEquals("stop that action", decoded.text) + } + + @Test + fun sessionCorrectionResultDecodesEmptyObjectToNulls() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionCorrectionResult.serializer(), + payload, + ) + + assertNull(decoded.status) + assertNull(decoded.text) + } + + @Test + fun rpcMethodsRegistrationMatchesControlContract() { + assertEquals("session.interrupt", RpcMethods.SESSION_INTERRUPT.name) + assertEquals("session.steer", RpcMethods.SESSION_STEER.name) + assertEquals("session.redirect", RpcMethods.SESSION_REDIRECT.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_INTERRUPT)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_STEER)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_REDIRECT)) + } +} 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 a943144a..99e121a5 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 @@ -6343,7 +6343,13 @@ class ChatViewModelTest { viewModel.interruptSession() advanceUntilIdle() - verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, mapOf("session_id" to sessionId), any()) } + verify { + HermesWsClient.send( + WsMethods.SESSION_INTERRUPT, + match { it["session_id"] == JsonPrimitive(sessionId) && it.size == 1 }, + any(), + ) + } } @Test From 1dd8d2138da308834b18c7eb47b21ff9fb5288ee Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:04:10 +0300 Subject: [PATCH 068/107] fix(#1379): send only contract params in subagent.tail (#1383) --- .../data/ws/SubagentRepository.kt | 11 +++--- .../ui/chat/ChatSubagentsDelegate.kt | 2 -- .../data/ws/SubagentRepositoryTest.kt | 36 +++++++++++++++++++ .../ui/chat/ChatSubagentsDelegateTest.kt | 12 +++---- 4 files changed, 46 insertions(+), 15 deletions(-) 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 1cda13ec..65ca1cb5 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 @@ -55,17 +55,14 @@ object SubagentRepository { suspend fun tailSubagent( sessionId: String, subagentId: String, - maxBytes: Int = 16384, ): SubagentTailResponse? { - if (subagentId.isBlank()) return null + // Contract (SubagentIdParams): session_id and subagent_id are required, extra keys forbidden (#1379). + if (subagentId.isBlank() || sessionId.isBlank()) return null val params = - mutableMapOf( + mapOf( + "session_id" to sessionId, "subagent_id" to subagentId, - "max_bytes" to maxBytes, ) - if (sessionId.isNotBlank()) { - params["session_id"] = sessionId - } val result = HermesWsClient .request( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt index eaa56386..0685c0ea 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt @@ -27,7 +27,6 @@ class ChatSubagentsDelegate( ) { companion object { private const val TAG = "ChatSubagentsDelegate" - const val SUBAGENT_TAIL_MAX_BYTES = 16384 } private var pollingJob: Job? = null @@ -130,7 +129,6 @@ class ChatSubagentsDelegate( subagentRepository.tailSubagent( sessionId = sessionId, subagentId = subagentId, - maxBytes = SUBAGENT_TAIL_MAX_BYTES, ) if (tailResponse != null) { diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt index 10fc76d9..f1559c81 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt @@ -3,6 +3,14 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.model.SubagentListItem import com.m57.hermescontrol.data.model.SubagentListResponse import com.m57.hermescontrol.data.model.SubagentTailResponse +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -11,6 +19,34 @@ import org.junit.Assert.assertTrue import org.junit.Test class SubagentRepositoryTest { + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun tailSubagentSendsExactlySessionIdAndSubagentId() { + // #1379: the contract forbids extra keys (max_bytes) and requires session_id. + val captured = slot>() + mockkObject(HermesWsClient) + every { HermesWsClient.request(WsMethods.SUBAGENT_TAIL, capture(captured), any(), any()) } returns + CompletableDeferred(mapOf("subagent_id" to "sub-1")) + + runBlocking { SubagentRepository.tailSubagent("s1", "sub-1") } + + assertEquals(mapOf("session_id" to "s1", "subagent_id" to "sub-1"), captured.captured) + } + + @Test + fun tailSubagentSkipsTheCallWhenAnIdIsBlank() { + mockkObject(HermesWsClient) + runBlocking { + assertNull(SubagentRepository.tailSubagent("", "sub-1")) + assertNull(SubagentRepository.tailSubagent("s1", " ")) + } + verify(exactly = 0) { HermesWsClient.request(any(), any(), any(), any()) } + } + @Test fun testWsMethodsConstants() { assertEquals("subagent.list", WsMethods.SUBAGENT_LIST) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt index a3179f5d..cf4a55ab 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt @@ -448,7 +448,7 @@ class ChatSubagentsDelegateTest { ), ) - coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returnsMany + coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returnsMany listOf( SubagentTailResponse(subagentId = "sub-1", text = "Line 1\n", truncated = false), SubagentTailResponse(subagentId = "sub-1", text = "Line 1\nLine 2\n", truncated = false), @@ -485,7 +485,7 @@ class ChatSubagentsDelegateTest { ), ) - coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns + coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns SubagentTailResponse(subagentId = "sub-1", text = "Finished output", truncated = false) delegate.inspectSubagentTranscript("sub-1") @@ -507,7 +507,7 @@ class ChatSubagentsDelegateTest { advanceTimeBy(3000) // Verify tailSubagent was called at most once or twice, not indefinitely - coVerify(atMost = 2) { mockRepository.tailSubagent("session-123", "sub-1", 16384) } + coVerify(atMost = 2) { mockRepository.tailSubagent("session-123", "sub-1") } delegate.closeSubagentTranscript() } @@ -526,7 +526,7 @@ class ChatSubagentsDelegateTest { ), ) - coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns + coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns SubagentTailResponse(subagentId = "sub-1", text = "Output") delegate.inspectSubagentTranscript("sub-1") @@ -553,7 +553,7 @@ class ChatSubagentsDelegateTest { ), ) - coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } throws + coEvery { mockRepository.tailSubagent("session-123", "sub-1") } throws RuntimeException("Gateway connection error") delegate.inspectSubagentTranscript("sub-1") @@ -565,7 +565,7 @@ class ChatSubagentsDelegateTest { assertFalse(transcript?.isLoading == true) // Now recover on retry - coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns + coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns SubagentTailResponse(subagentId = "sub-1", text = "Recovered logs") delegate.retryTranscript() From c5b34ff4713cf54c4cea50a49fcdf18a7171c0a5 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:09:45 +0300 Subject: [PATCH 069/107] refactor(#1375): typed params for remaining ChatViewModel session.* and prompt.btw (#1384) --- .../data/ws/contract/RpcMethod.kt | 58 ++ .../data/ws/contract/SessionMisc.kt | 41 ++ .../hermescontrol/ui/chat/ChatViewModel.kt | 96 ++- .../data/ws/HermesWsClientSessionMiscTest.kt | 584 ++++++++++++++++++ .../data/ws/RawRpcParamsRatchetTest.kt | 9 - .../data/ws/contract/SessionMiscTypesTest.kt | 295 +++++++++ .../ui/chat/ChatCompressRpcTest.kt | 12 +- .../ui/chat/ChatViewModelTest.kt | 59 +- 8 files changed, 1066 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionMisc.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionMiscTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionMiscTypesTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index e89ba820..5b6c329d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws.contract import com.m57.hermescontrol.data.ws.WsMethods import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.JsonElement class RpcMethod( val name: String, @@ -59,6 +60,56 @@ object RpcMethods { SessionCorrectionResult.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val SESSION_LIST: RpcMethod = + RpcMethod( + WsMethods.SESSION_LIST, + SessionListParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_BRANCH: RpcMethod = + RpcMethod( + WsMethods.SESSION_BRANCH, + SessionBranchParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_BRANCH_WHOLE: RpcMethod = + RpcMethod( + WsMethods.SESSION_BRANCH_WHOLE, + SessionBranchWholeParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_COMPRESS: RpcMethod = + RpcMethod( + WsMethods.SESSION_COMPRESS, + SessionCompressParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_CONTEXT_BREAKDOWN: RpcMethod = + RpcMethod( + WsMethods.SESSION_CONTEXT_BREAKDOWN, + SessionIdParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_USAGE: RpcMethod = + RpcMethod( + WsMethods.SESSION_USAGE, + SessionIdParams.serializer(), + JsonElement.serializer(), + ) + + val PROMPT_BTW: RpcMethod = + RpcMethod( + WsMethods.PROMPT_BTW, + PromptBtwParams.serializer(), + PromptBtwResult.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -69,5 +120,12 @@ object RpcMethods { SESSION_INTERRUPT, SESSION_STEER, SESSION_REDIRECT, + SESSION_LIST, + SESSION_BRANCH, + SESSION_BRANCH_WHOLE, + SESSION_COMPRESS, + SESSION_CONTEXT_BREAKDOWN, + SESSION_USAGE, + PROMPT_BTW, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionMisc.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionMisc.kt new file mode 100644 index 00000000..54b7e59d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionMisc.kt @@ -0,0 +1,41 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data object SessionListParams + +@Serializable +data class SessionIdParams( + @SerialName("session_id") val sessionId: String, +) + +@Serializable +data class SessionBranchParams( + @SerialName("session_id") val sessionId: String, + @SerialName("name") val name: String? = null, +) + +@Serializable +data class SessionBranchWholeParams( + @SerialName("session_id") val sessionId: String, + @SerialName("name") val name: String? = null, +) + +@Serializable +data class SessionCompressParams( + @SerialName("session_id") val sessionId: String, + @SerialName("focus_topic") val focusTopic: String? = null, +) + +@Serializable +data class PromptBtwParams( + @SerialName("session_id") val sessionId: String, + @SerialName("text") val text: String, +) + +@Serializable +data class PromptBtwResult( + @SerialName("task_id") val taskId: String? = 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 1f9aac39..91eb50aa 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 @@ -37,9 +37,17 @@ 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.contract.PromptBtwParams import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionBranchParams +import com.m57.hermescontrol.data.ws.contract.SessionBranchWholeParams +import com.m57.hermescontrol.data.ws.contract.SessionCompressParams import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams +import com.m57.hermescontrol.data.ws.contract.SessionCreateParams +import com.m57.hermescontrol.data.ws.contract.SessionIdParams import com.m57.hermescontrol.data.ws.contract.SessionInterruptParams +import com.m57.hermescontrol.data.ws.contract.SessionListParams +import com.m57.hermescontrol.data.ws.contract.SessionResumeParams import com.m57.hermescontrol.data.ws.toAny import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.notification.ReplyNotificationTracker @@ -509,7 +517,7 @@ class ChatViewModel( private data class PendingBranchRequest( val generation: Long, - val params: Map, + val params: SessionBranchWholeParams, ) private val sessionRequestById = ConcurrentHashMap() @@ -2225,12 +2233,12 @@ class ChatViewModel( (error as? JsonRpcError)?.code ?: (error as? Map<*, *>)?.get("code") as? Int if (code == -32601 && pendingBranch != null && pendingBranch.generation == sessionGeneration) { - val params = pendingBranch.params + val p = pendingBranch.params val generation = pendingBranch.generation viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_BRANCH, - params, + RpcMethods.SESSION_BRANCH, + SessionBranchParams(sessionId = p.sessionId, name = p.name), onSent = { branchId -> trackSessionRequest(branchId, WsMethods.SESSION_BRANCH, generation) }, @@ -3578,18 +3586,6 @@ class ChatViewModel( private fun attachmentTooLargeMessage(attachment: Attachment): String = "Attachment too large: ${attachment.name} (maximum 10 MB)" - /** - * Send a JSON-RPC call and suspend until the response arrives, delegating - * the deferred + 120s timeout to [HermesWsClient.request] (issue #526). - * Throws [HermesWsClient.HermesRpcException] on RPC error, or - * [kotlinx.coroutines.TimeoutCancellationException] if the server never - * answers within the timeout. - */ - private suspend fun sendRpcAndAwait( - method: String, - params: Map, - ): Any? = HermesWsClient.request(method, params).await() - // ── Attachment management ───────────────────────────────────────────── /** @@ -3942,13 +3938,12 @@ class ChatViewModel( viewModelScope.launch(ioDispatcher) { try { - val rpcResult = + val taskId = wsClient - .request( - WsMethods.PROMPT_BTW, - mapOf("session_id" to sessionId, "text" to trimmed), - ).await() - val taskId = (rpcResult as? Map<*, *>)?.get("task_id") as? String + .call( + RpcMethods.PROMPT_BTW, + PromptBtwParams(sessionId = sessionId, text = trimmed), + ).taskId if (!taskId.isNullOrBlank()) { _uiState.update { state -> state.btwState?.let { current -> @@ -4034,12 +4029,11 @@ class ChatViewModel( return } val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() - val params = mutableMapOf("session_id" to sessionId) - if (arg.isNotBlank()) params["name"] = arg + val params = SessionBranchWholeParams(sessionId = sessionId, name = arg.takeIf { it.isNotBlank() }) val generation = sessionGeneration viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_BRANCH_WHOLE, + RpcMethods.SESSION_BRANCH_WHOLE, params, onSent = { id -> branchWholeRequests[id] = PendingBranchRequest(generation, params) @@ -4197,21 +4191,16 @@ class ChatViewModel( viewModelScope.launch(ioDispatcher) { try { - val params = - buildMap { - put("session_id", sessionId) - if (focusTopic.isNotBlank()) { - put("focus_topic", focusTopic) - } - } // Use extended timeout (e.g. 300_000L / 5 minutes) so LLM summarization doesn't timeout val result = - wsClient - .request( - WsMethods.SESSION_COMPRESS, - params, - timeoutMs = 300_000L, - ).await() + wsClient.call( + RpcMethods.SESSION_COMPRESS, + SessionCompressParams( + sessionId = sessionId, + focusTopic = focusTopic.takeIf { it.isNotBlank() }, + ), + timeoutMs = 300_000L, + ) handleCompressionResult(result) } catch (e: HermesWsClient.HermesRpcException) { @@ -4439,8 +4428,8 @@ class ChatViewModel( val generation = resetSessionState(sessionId = null, title = "Hermes", isLoading = setLoading) viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_CREATE, - params = mapOf("source" to "desktop"), + RpcMethods.SESSION_CREATE, + SessionCreateParams(source = "desktop"), onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) }, ) } @@ -4460,7 +4449,8 @@ class ChatViewModel( fun loadSessions() { viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_LIST, + RpcMethods.SESSION_LIST, + SessionListParams, onSent = { id -> trackRequest(id, WsMethods.SESSION_LIST) }, ) } @@ -5254,16 +5244,14 @@ class ChatViewModel( val connectionCheckpoint = connectionOperationDelegate.resumeCheckpoint() val profile = AuthManager.activeProfileId.value val params = - mutableMapOf( - "session_id" to sessionId, - "omit_messages" to true, + SessionResumeParams( + sessionId = sessionId, + omitMessages = true, + profile = profile?.takeIf { it.isNotBlank() }, ) - if (!profile.isNullOrBlank()) { - params["profile"] = profile - } viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_RESUME, + RpcMethods.SESSION_RESUME, params, onSent = { id -> trackSessionRequest( @@ -5755,9 +5743,9 @@ class ChatViewModel( if (rpcSessionId != null) { try { val result = - sendRpcAndAwait( - WsMethods.SESSION_CONTEXT_BREAKDOWN, - mapOf("session_id" to rpcSessionId), + wsClient.call( + RpcMethods.SESSION_CONTEXT_BREAKDOWN, + SessionIdParams(rpcSessionId), ) coroutineContext.ensureActive() val ctx = parseContextBreakdown(result) @@ -5794,9 +5782,9 @@ class ChatViewModel( if (!isSwitchPending) { try { val usage = - sendRpcAndAwait( - WsMethods.SESSION_USAGE, - mapOf("session_id" to rpcSessionId), + wsClient.call( + RpcMethods.SESSION_USAGE, + SessionIdParams(rpcSessionId), ) coroutineContext.ensureActive() val snapshot = parseUsageSnapshot(usage) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionMiscTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionMiscTest.kt new file mode 100644 index 00000000..edd2a00e --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionMiscTest.kt @@ -0,0 +1,584 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.PromptBtwParams +import com.m57.hermescontrol.data.ws.contract.PromptBtwResult +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionBranchParams +import com.m57.hermescontrol.data.ws.contract.SessionBranchWholeParams +import com.m57.hermescontrol.data.ws.contract.SessionCompressParams +import com.m57.hermescontrol.data.ws.contract.SessionIdParams +import com.m57.hermescontrol.data.ws.contract.SessionListParams +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientSessionMiscTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun typedSendSessionListPutsEmptyParamsOnWireWithoutActiveProfile() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.list") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.send( + method = RpcMethods.SESSION_LIST, + params = SessionListParams, + onSent = { id -> onSentId = id }, + ) + + assertTrue(returnedId.isNotBlank()) + assertEquals(returnedId, onSentId) + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("session.list", (frame["method"] as? JsonPrimitive)?.content) + assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content) + + // An empty params object is omitted on the wire (encodeDefaults=false), same as before the migration. + val params = frame["params"] as? JsonObject + assertTrue("session.list carries no params", params == null || params.isEmpty()) + } + + @Test + fun typedSendSessionListWithActiveProfileInjectsProfileOnWire() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.list") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + AuthManager.setActiveProfileId("active-profile-sessions") + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + HermesWsClient.send( + method = RpcMethods.SESSION_LIST, + params = SessionListParams, + ) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("profile", "active-profile-sessions") + } + assertEquals(expectedParams, params) + } + + @Test + fun typedCallPromptBtwRoundTripsTaskIdAndReturnsPromptBtwResult() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "prompt.btw") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":{"task_id":"task-async-123","status":"queued"}}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: PromptBtwResult = + runBlocking { + HermesWsClient.call( + RpcMethods.PROMPT_BTW, + PromptBtwParams(sessionId = "sess-btw-call", text = "remember this detail"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("prompt.btw", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-btw-call") + put("text", "remember this detail") + } + assertEquals(expectedParams, frame["params"]) + assertEquals("task-async-123", result.taskId) + } + + @Test + fun typedCallSessionUsageReturnsRawResultJsonElementUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("prompt_tokens", 1024) + put("completion_tokens", 256) + put("total_tokens", 1280) + put("cost_usd", 0.015) + put( + "nested_stats", + buildJsonObject { + put("cache_read_tokens", 512) + put("reasoning_tokens", 64) + }, + ) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.usage") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_USAGE, + SessionIdParams(sessionId = "s1"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.usage", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "s1") + } + assertEquals(expectedParams, frame["params"]) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallSessionCompressWithCustomTimeoutPutsExactWirePayload() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.compress") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":{"status":"compressed"}}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + method = RpcMethods.SESSION_COMPRESS, + params = SessionCompressParams(sessionId = "sess-long-comp", focusTopic = "deep-dive"), + timeoutMs = 300_000L, + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.compress", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-long-comp") + put("focus_topic", "deep-dive") + } + assertEquals(expectedParams, frame["params"]) + + val expectedResult = + buildJsonObject { + put("status", "compressed") + } + assertEquals(expectedResult, result) + } + + @Test + fun typedCallSessionBranchPutsExactWirePayloadAndDecodesPassthroughResult() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResult = + buildJsonObject { + put("new_session_id", "sess-branched-99") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.branch") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResult}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_BRANCH, + SessionBranchParams(sessionId = "sess-orig-1", name = "branch-name"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.branch", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-orig-1") + put("name", "branch-name") + } + assertEquals(expectedParams, frame["params"]) + assertEquals(serverResult, result) + } + + @Test + fun typedCallSessionBranchWholePutsExactWirePayloadAndDecodesPassthroughResult() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResult = + buildJsonObject { + put("whole_branch_session_id", "sess-whole-branched-101") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.branch_whole") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResult}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_BRANCH_WHOLE, + SessionBranchWholeParams(sessionId = "sess-whole-orig-1", name = null), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.branch_whole", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-whole-orig-1") + } + assertEquals(expectedParams, frame["params"]) + assertEquals(serverResult, result) + } + + @Test + fun typedCallSessionContextBreakdownPutsExactWirePayloadAndDecodesPassthroughResult() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResult = + buildJsonObject { + put("system_prompt", 450) + put("messages", 2300) + put("tools", 800) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.context_breakdown") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResult}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_CONTEXT_BREAKDOWN, + SessionIdParams(sessionId = "sess-bd-1"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.context_breakdown", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-bd-1") + } + assertEquals(expectedParams, frame["params"]) + assertEquals(serverResult, result) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index ea8f658d..37c39ff9 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -372,15 +372,6 @@ class RawRpcParamsRatchetTest { "ui/chat/ChatViewModel.kt::CONFIG_SET", "ui/chat/ChatViewModel.kt::FILE_ATTACH", "ui/chat/ChatViewModel.kt::IMAGE_ATTACH_BYTES", - "ui/chat/ChatViewModel.kt::PROMPT_BTW", - "ui/chat/ChatViewModel.kt::SESSION_BRANCH", - "ui/chat/ChatViewModel.kt::SESSION_BRANCH_WHOLE", - "ui/chat/ChatViewModel.kt::SESSION_COMPRESS", - "ui/chat/ChatViewModel.kt::SESSION_CONTEXT_BREAKDOWN", - "ui/chat/ChatViewModel.kt::SESSION_CREATE", - "ui/chat/ChatViewModel.kt::SESSION_LIST", - "ui/chat/ChatViewModel.kt::SESSION_RESUME", - "ui/chat/ChatViewModel.kt::SESSION_USAGE", "ui/chat/ChatViewModel.kt::SLASH_EXEC", "ui/process/ProcessesViewModel.kt::PROCESS_KILL", "ui/process/ProcessesViewModel.kt::PROCESS_LIST", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionMiscTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionMiscTypesTest.kt new file mode 100644 index 00000000..f32892a1 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionMiscTypesTest.kt @@ -0,0 +1,295 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionMiscTypesTest { + @Test + fun sessionListParamsEncodesToEmptyJsonObject() { + val params = SessionListParams + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionListParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun sessionIdParamsEncodesSessionIdExactly() { + val params = SessionIdParams(sessionId = "sess-misc-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionIdParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-misc-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchParamsEncodesSessionIdOnlyWhenNameNull() { + val params = SessionBranchParams(sessionId = "sess-branch-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-branch-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchParamsEncodesSessionIdAndNameWhenPresent() { + val params = SessionBranchParams(sessionId = "sess-branch-2", name = "experiment-fork") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-branch-2") + put("name", "experiment-fork") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchParamsPassesBlankNameThroughAsIs() { + val params = SessionBranchParams(sessionId = "sess-branch-3", name = " ") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-branch-3") + put("name", " ") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchWholeParamsEncodesSessionIdOnlyWhenNameNull() { + val params = SessionBranchWholeParams(sessionId = "sess-whole-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-whole-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchWholeParamsEncodesSessionIdAndNameWhenPresent() { + val params = SessionBranchWholeParams(sessionId = "sess-whole-2", name = "full-tree-fork") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-whole-2") + put("name", "full-tree-fork") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchWholeParamsPassesBlankNameThroughAsIs() { + val params = SessionBranchWholeParams(sessionId = "sess-whole-3", name = " \t ") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-whole-3") + put("name", " \t ") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys) + } + + @Test + fun sessionCompressParamsEncodesSessionIdOnlyWhenFocusTopicNull() { + val params = SessionCompressParams(sessionId = "sess-comp-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-comp-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun sessionCompressParamsEncodesSessionIdAndFocusTopicWhenPresent() { + val params = SessionCompressParams(sessionId = "sess-comp-2", focusTopic = "summarize-db-schema") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-comp-2") + put("focus_topic", "summarize-db-schema") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "focus_topic"), (encoded as JsonObject).keys) + } + + @Test + fun sessionCompressParamsPassesBlankFocusTopicThroughAsIs() { + val params = SessionCompressParams(sessionId = "sess-comp-3", focusTopic = " ") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-comp-3") + put("focus_topic", " ") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "focus_topic"), (encoded as JsonObject).keys) + } + + @Test + fun promptBtwParamsEncodesSessionIdAndText() { + val params = PromptBtwParams(sessionId = "sess-btw-1", text = "by the way, take note") + val encoded = OkHttpProvider.json.encodeToJsonElement(PromptBtwParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-btw-1") + put("text", "by the way, take note") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys) + } + + @Test + fun promptBtwResultDecodesFullPayloadAndIgnoresUnknownKeys() { + val payload = + buildJsonObject { + put("task_id", "task-xyz-987") + put("unknown_flag", true) + put("extra_num", 100) + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + PromptBtwResult.serializer(), + payload, + ) + + assertEquals("task-xyz-987", decoded.taskId) + } + + @Test + fun promptBtwResultDecodesEmptyObjectToNulls() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + PromptBtwResult.serializer(), + payload, + ) + + assertNull(decoded.taskId) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("session.list", RpcMethods.SESSION_LIST.name) + assertEquals("session.branch", RpcMethods.SESSION_BRANCH.name) + assertEquals("session.branch_whole", RpcMethods.SESSION_BRANCH_WHOLE.name) + assertEquals("session.compress", RpcMethods.SESSION_COMPRESS.name) + assertEquals("session.context_breakdown", RpcMethods.SESSION_CONTEXT_BREAKDOWN.name) + assertEquals("session.usage", RpcMethods.SESSION_USAGE.name) + assertEquals("prompt.btw", RpcMethods.PROMPT_BTW.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_LIST)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_BRANCH)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_BRANCH_WHOLE)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_COMPRESS)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_CONTEXT_BREAKDOWN)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_USAGE)) + assertTrue(RpcMethods.all.contains(RpcMethods.PROMPT_BTW)) + } + + @Test + fun jsonElementPassthroughMethodsDecodeResultUntouched() { + val arbitraryPayload = + buildJsonObject { + put("status", "ok") + put("tokens", 4567) + put("nested", buildJsonObject { put("item", "val") }) + } + + val decodedList: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_LIST.result, + arbitraryPayload, + ) + val decodedBranch: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_BRANCH.result, + arbitraryPayload, + ) + val decodedBranchWhole: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_BRANCH_WHOLE.result, + arbitraryPayload, + ) + val decodedCompress: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_COMPRESS.result, + arbitraryPayload, + ) + val decodedBreakdown: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_CONTEXT_BREAKDOWN.result, + arbitraryPayload, + ) + val decodedUsage: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_USAGE.result, + arbitraryPayload, + ) + + assertEquals(arbitraryPayload, decodedList) + assertEquals(arbitraryPayload, decodedBranch) + assertEquals(arbitraryPayload, decodedBranchWhole) + assertEquals(arbitraryPayload, decodedCompress) + assertEquals(arbitraryPayload, decodedBreakdown) + assertEquals(arbitraryPayload, decodedUsage) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt index daed040b..3e0294ab 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt @@ -201,7 +201,7 @@ class ChatCompressRpcTest { val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) val params = paramsCalls[compressIndex] - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic")) } @@ -224,8 +224,8 @@ class ChatCompressRpcTest { val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) val params = paramsCalls[compressIndex] - assertEquals(sessionId, params["session_id"]) - assertEquals("auth decisions", params["focus_topic"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) + assertEquals(JsonPrimitive("auth decisions"), params["focus_topic"]) } @Test @@ -247,7 +247,7 @@ class ChatCompressRpcTest { val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) val params = paramsCalls[compressIndex] - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic")) assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls) } @@ -271,8 +271,8 @@ class ChatCompressRpcTest { val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) val params = paramsCalls[compressIndex] - assertEquals(sessionId, params["session_id"]) - assertEquals("architecture details", params["focus_topic"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) + assertEquals(JsonPrimitive("architecture details"), params["focus_topic"]) } @Test 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 99e121a5..e69f29a0 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 @@ -63,6 +63,7 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject @@ -767,8 +768,8 @@ class ChatViewModelTest { val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } assertNotNull("session.branch_whole should be dispatched for /fork", branchSent) - assertEquals(sessionId, branchSent!!.second["session_id"]) - assertEquals("my-fork", branchSent.second["name"]) + assertEquals(JsonPrimitive(sessionId), branchSent!!.second["session_id"]) + assertEquals(JsonPrimitive("my-fork"), branchSent.second["name"]) } @Test @@ -813,7 +814,7 @@ class ChatViewModelTest { val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } assertNotNull("session.branch_whole should be dispatched for /fork", branchSent) - assertEquals(sessionId, branchSent!!.second["session_id"]) + assertEquals(JsonPrimitive(sessionId), branchSent!!.second["session_id"]) assertFalse("name param should be omitted when no title given", branchSent.second.containsKey("name")) } @@ -876,8 +877,8 @@ class ChatViewModelTest { val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } assertNotNull("session.branch_whole should be sent", branchWholeSent) - assertEquals("my-retry-branch", branchWholeSent!!.second["name"]) - assertEquals(sessionId, branchWholeSent.second["session_id"]) + assertEquals(JsonPrimitive("my-retry-branch"), branchWholeSent!!.second["name"]) + assertEquals(JsonPrimitive(sessionId), branchWholeSent.second["session_id"]) // Backend answers -32601 unknown method mockEventsFlow.emit( @@ -891,8 +892,8 @@ class ChatViewModelTest { // Retries ONCE with session.branch using same params val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH } assertNotNull("session.branch fallback should be sent", branchSent) - assertEquals("my-retry-branch", branchSent!!.second["name"]) - assertEquals(sessionId, branchSent.second["session_id"]) + assertEquals(JsonPrimitive("my-retry-branch"), branchSent!!.second["name"]) + assertEquals(JsonPrimitive(sessionId), branchSent.second["session_id"]) assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH }) // session.branch result is then handled @@ -1077,7 +1078,12 @@ class ChatViewModelTest { verify { HermesWsClient.request( WsMethods.PROMPT_BTW, - mapOf("session_id" to sessionId, "text" to "which file was that in?"), + JsonObject( + mapOf( + "session_id" to JsonPrimitive(sessionId), + "text" to JsonPrimitive("which file was that in?"), + ), + ), any(), ) } @@ -1573,7 +1579,12 @@ class ChatViewModelTest { verify { HermesWsClient.send( WsMethods.SESSION_RESUME, - mapOf("session_id" to "session-from-notification", "omit_messages" to true), + JsonObject( + mapOf( + "session_id" to JsonPrimitive("session-from-notification"), + "omit_messages" to JsonPrimitive(true), + ), + ), any(), ) } @@ -4983,7 +4994,12 @@ class ChatViewModelTest { verify { HermesWsClient.send( WsMethods.SESSION_RESUME, - mapOf("session_id" to "session-456", "omit_messages" to true), + JsonObject( + mapOf( + "session_id" to JsonPrimitive("session-456"), + "omit_messages" to JsonPrimitive(true), + ), + ), any(), ) } @@ -4996,7 +5012,7 @@ class ChatViewModelTest { val (viewModel, _) = createViewModelWithSession() val resumeRequests = mutableMapOf() every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers { - val sessionId = arg>(1).getValue("session_id") + val sessionId = (arg>(1).getValue("session_id") as JsonPrimitive).content val requestId = "resume-$sessionId" resumeRequests[sessionId] = requestId arg<((String) -> Unit)?>(2)?.invoke(requestId) @@ -5090,7 +5106,7 @@ class ChatViewModelTest { val (viewModel, _) = createViewModelWithSession() val resumeRequests = mutableMapOf() every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers { - val sessionId = arg>(1).getValue("session_id") + val sessionId = (arg>(1).getValue("session_id") as JsonPrimitive).content val requestId = "resume-$sessionId" resumeRequests[sessionId] = requestId arg<((String) -> Unit)?>(2)?.invoke(requestId) @@ -5689,14 +5705,14 @@ class ChatViewModelTest { verify(exactly = 0) { HermesWsClient.request( WsMethods.SESSION_CONTEXT_BREAKDOWN, - match { it["session_id"] == "session-456" }, + match { it["session_id"] == JsonPrimitive("session-456") }, any(), ) } verify(exactly = 0) { HermesWsClient.request( WsMethods.SESSION_USAGE, - match { it["session_id"] == "session-456" }, + match { it["session_id"] == JsonPrimitive("session-456") }, any(), ) } @@ -5718,18 +5734,18 @@ class ChatViewModelTest { verify { HermesWsClient.request( WsMethods.SESSION_CONTEXT_BREAKDOWN, - match { it["session_id"] == "runtime-456" }, + match { it["session_id"] == JsonPrimitive("runtime-456") }, any(), ) } verify { HermesWsClient.request( WsMethods.SESSION_USAGE, - match { it["session_id"] == "runtime-456" }, + match { it["session_id"] == JsonPrimitive("runtime-456") }, any(), ) } - assertEquals("runtime-456", paramsSlot.captured["session_id"]) + assertEquals(JsonPrimitive("runtime-456"), paramsSlot.captured["session_id"]) } @Test @@ -5833,7 +5849,12 @@ class ChatViewModelTest { verify { HermesWsClient.send( WsMethods.SESSION_RESUME, - mapOf("session_id" to "session-456", "omit_messages" to true), + JsonObject( + mapOf( + "session_id" to JsonPrimitive("session-456"), + "omit_messages" to JsonPrimitive(true), + ), + ), any(), ) } @@ -9476,7 +9497,7 @@ class ChatViewModelTest { any(), ) } - assertEquals("session-stored-999", resumeParamsSlot.captured["session_id"]) + assertEquals(JsonPrimitive("session-stored-999"), resumeParamsSlot.captured["session_id"]) } @Test From 717b5b0361224a0e7dd98b170cd9c0b7d4a057e3 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:51:02 +0300 Subject: [PATCH 070/107] refactor(#1375): typed params for approval.* and session.active_list (#1385) --- .../data/ws/SessionLiveStatusSource.kt | 8 +- .../data/ws/contract/Approvals.kt | 28 +++ .../data/ws/contract/RpcMethod.kt | 44 ++++ .../ui/chat/ChatApprovalsDelegate.kt | 42 ++-- .../hermescontrol/ui/chat/ChatViewModel.kt | 11 +- .../data/ws/HermesWsClientApprovalsTest.kt | 237 ++++++++++++++++++ .../data/ws/RawRpcParamsRatchetTest.kt | 4 - .../data/ws/SessionLiveStatusSourceTest.kt | 14 +- .../data/ws/contract/ApprovalsTypesTest.kt | 209 +++++++++++++++ .../ui/chat/ChatApprovalsDelegateTest.kt | 51 ++-- .../ui/chat/ChatViewModelTest.kt | 26 +- 11 files changed, 611 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/Approvals.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientApprovalsTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ApprovalsTypesTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt index 31cc88b4..9d8964c7 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt @@ -3,6 +3,8 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.model.ActiveSessionsResponse import com.m57.hermescontrol.data.model.LiveSessionSnapshot import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionActiveListParams import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -148,8 +150,8 @@ object SessionLiveStatusDecoder { * Production implementation of [SessionLiveStatusSource] backed by [HermesWsClient]. */ class HermesSessionLiveStatusSource( - private val rpcRequest: suspend (method: String, params: Map) -> Any? = { method, params -> - HermesWsClient.request(method, params).await() + private val rpcRequest: suspend () -> Any? = { + HermesWsClient.call(RpcMethods.SESSION_ACTIVE_LIST, SessionActiveListParams()) }, eventsProvider: () -> Flow = { HermesWsClient.events }, connectionStatusProvider: () -> StateFlow = { HermesWsClient.connectionStatus }, @@ -159,7 +161,7 @@ class HermesSessionLiveStatusSource( override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? = try { - val raw = rpcRequest(WsMethods.SESSION_ACTIVE_LIST, emptyMap()) + val raw = rpcRequest() SessionLiveStatusDecoder.decodeSnapshot(raw) } catch (e: CancellationException) { throw e diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Approvals.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Approvals.kt new file mode 100644 index 00000000..d66920eb --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Approvals.kt @@ -0,0 +1,28 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ApprovalPendingParams( + @SerialName("session_id") val sessionId: String, +) + +@Serializable +data class ApprovalReceivedParams( + @SerialName("session_id") val sessionId: String, + @SerialName("request_id") val requestId: String, +) + +@Serializable +data class ApprovalRespondParams( + @SerialName("session_id") val sessionId: String, + @SerialName("choice") val choice: String? = null, + @SerialName("all") val all: Boolean? = null, + @SerialName("request_id") val requestId: String? = null, +) + +@Serializable +data class SessionActiveListParams( + @SerialName("current_session_id") val currentSessionId: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index 5b6c329d..3d817fce 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -10,6 +10,14 @@ class RpcMethod( val result: KSerializer, ) +interface TypedRpcSender { + fun

send( + method: RpcMethod, + params: P, + onSent: ((String) -> Unit)? = null, + ): String +} + object RpcMethods { val SESSION_EVENTS_SINCE: RpcMethod = RpcMethod( @@ -110,6 +118,38 @@ object RpcMethods { PromptBtwResult.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val APPROVAL_PENDING: RpcMethod = + RpcMethod( + WsMethods.APPROVAL_PENDING, + ApprovalPendingParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val APPROVAL_RECEIVED: RpcMethod = + RpcMethod( + WsMethods.APPROVAL_RECEIVED, + ApprovalReceivedParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val APPROVAL_RESPOND: RpcMethod = + RpcMethod( + WsMethods.APPROVAL_RESPOND, + ApprovalRespondParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val SESSION_ACTIVE_LIST: RpcMethod = + RpcMethod( + WsMethods.SESSION_ACTIVE_LIST, + SessionActiveListParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -127,5 +167,9 @@ object RpcMethods { SESSION_CONTEXT_BREAKDOWN, SESSION_USAGE, PROMPT_BTW, + APPROVAL_PENDING, + APPROVAL_RECEIVED, + APPROVAL_RESPOND, + SESSION_ACTIVE_LIST, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt index 32d68d66..313e57de 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt @@ -2,6 +2,11 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ApprovalPendingParams +import com.m57.hermescontrol.data.ws.contract.ApprovalReceivedParams +import com.m57.hermescontrol.data.ws.contract.ApprovalRespondParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcSender import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -22,7 +27,7 @@ class ChatApprovalsDelegate( private val ioDispatcher: CoroutineDispatcher, private val uiState: MutableStateFlow, private val runtimeSessionId: () -> String?, - private val wsSend: (method: String, params: Map, onSent: ((String) -> Unit)?) -> Unit, + private val rpc: TypedRpcSender, private val trackRequest: (id: String, method: String) -> Unit, private val addSystemMessage: (text: String) -> Unit, private val respondToServerRequest: ((String, JsonElement) -> Unit)? = null, @@ -99,13 +104,12 @@ class ChatApprovalsDelegate( if (event.serverRequestId == null && requestId != null && sessionId != null) { scope.launch(ioDispatcher) { runCatching { - wsSend( - WsMethods.APPROVAL_RECEIVED, - mapOf( - "session_id" to sessionId, - "request_id" to requestId, + rpc.send( + RpcMethods.APPROVAL_RECEIVED, + ApprovalReceivedParams( + sessionId = sessionId, + requestId = requestId, ), - null, ) } } @@ -115,9 +119,9 @@ class ChatApprovalsDelegate( fun replayPendingApproval(sessionId: String) { val targetSessionId = runtimeSessionId() ?: sessionId scope.launch(ioDispatcher) { - wsSend( - WsMethods.APPROVAL_PENDING, - mapOf("session_id" to targetSessionId), + rpc.send( + RpcMethods.APPROVAL_PENDING, + ApprovalPendingParams(sessionId = targetSessionId), ) { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) } } } @@ -190,16 +194,14 @@ class ChatApprovalsDelegate( ) return@launch } - val params = - mutableMapOf( - "session_id" to sessionId, - "choice" to choice, - "all" to false, - ) - if (requestId != null) params["request_id"] = requestId - wsSend( - WsMethods.APPROVAL_RESPOND, - params, + rpc.send( + RpcMethods.APPROVAL_RESPOND, + ApprovalRespondParams( + sessionId = sessionId, + choice = choice, + all = false, + requestId = requestId, + ), ) { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) } // The queue can hold more pendings — surface the next one. replayPendingApproval(sessionId) 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 91eb50aa..87ae2a2b 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 @@ -38,6 +38,7 @@ 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.contract.PromptBtwParams +import com.m57.hermescontrol.data.ws.contract.RpcMethod import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.data.ws.contract.SessionBranchParams import com.m57.hermescontrol.data.ws.contract.SessionBranchWholeParams @@ -48,6 +49,7 @@ import com.m57.hermescontrol.data.ws.contract.SessionIdParams import com.m57.hermescontrol.data.ws.contract.SessionInterruptParams import com.m57.hermescontrol.data.ws.contract.SessionListParams import com.m57.hermescontrol.data.ws.contract.SessionResumeParams +import com.m57.hermescontrol.data.ws.contract.TypedRpcSender import com.m57.hermescontrol.data.ws.toAny import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.notification.ReplyNotificationTracker @@ -711,7 +713,14 @@ class ChatViewModel( ioDispatcher = ioDispatcher, uiState = _uiState, runtimeSessionId = { runtimeSessionId }, - wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) }, + rpc = + object : TypedRpcSender { + override fun

send( + method: RpcMethod, + params: P, + onSent: ((String) -> Unit)?, + ): String = wsClient.send(method, params, onSent) + }, trackRequest = { id, method -> trackRequest(id, method) }, addSystemMessage = { text -> addSystemMessage(text) }, respondToServerRequest = { id, result -> wsClient.respondToServerRequest(id, result) }, diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientApprovalsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientApprovalsTest.kt new file mode 100644 index 00000000..8c6fd063 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientApprovalsTest.kt @@ -0,0 +1,237 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.ApprovalRespondParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionActiveListParams +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientApprovalsTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeoutOrNull(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } + } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun typedSendApprovalRespondPutsExactParamsOnWire() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "approval.respond") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.send( + method = RpcMethods.APPROVAL_RESPOND, + params = + ApprovalRespondParams( + sessionId = "sess-appr-wire-1", + choice = "deny", + all = false, + requestId = "req-appr-wire-42", + ), + onSent = { id -> onSentId = id }, + ) + + assertTrue(returnedId.isNotBlank()) + assertEquals(returnedId, onSentId) + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("approval.respond", (frame["method"] as? JsonPrimitive)?.content) + assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put("session_id", "sess-appr-wire-1") + put("choice", "deny") + put("all", false) + put("request_id", "req-appr-wire-42") + } + assertEquals(expectedParams, params) + assertEquals(false, (params?.get("all") as? JsonPrimitive)?.booleanOrNull) + } + + @Test + fun typedCallSessionActiveListReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("active_count", 2) + put( + "sessions", + buildJsonObject { + put("primary", "sess-alpha") + put("secondary", "sess-beta") + }, + ) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.active_list") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_ACTIVE_LIST, + SessionActiveListParams(), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.active_list", (frame["method"] as? JsonPrimitive)?.content) + + // An empty params object is omitted on wire or empty + val params = frame["params"] as? JsonObject + assertTrue( + "session.active_list with null currentSessionId carries no params", + params == null || params.isEmpty(), + ) + assertEquals(serverResultJson, result) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index 37c39ff9..9e7a02d1 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -339,7 +339,6 @@ class RawRpcParamsRatchetTest { "data/ws/PluginManageRepository.kt::PLUGINS_MANAGE", "data/ws/PluginRemovalRepository.kt::PLUGINS_MANAGE", "data/ws/ProjectsSource.kt::PROJECTS_LIST", - "data/ws/SessionLiveStatusSource.kt::SESSION_ACTIVE_LIST", "data/ws/SubagentRepository.kt::SUBAGENT_LIST", "data/ws/SubagentRepository.kt::SUBAGENT_TAIL", "data/ws/VaultRepository.kt::VAULT_LIST", @@ -355,9 +354,6 @@ class RawRpcParamsRatchetTest { "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", "ui/bots/group/GroupChatViewModel.kt::PROFILES_LIST", - "ui/chat/ChatApprovalsDelegate.kt::APPROVAL_PENDING", - "ui/chat/ChatApprovalsDelegate.kt::APPROVAL_RECEIVED", - "ui/chat/ChatApprovalsDelegate.kt::APPROVAL_RESPOND", "ui/chat/ChatConnectionOperationDelegate.kt::CONNECTION_RESPOND", "ui/chat/ChatConnectionOperationDelegate.kt::CONNECTION_RESPOND", "ui/chat/ChatConnectionOperationDelegate.kt::CONNECTORS_OPERATION_WAKE", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt index 4a32ef87..a10d23dd 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt @@ -121,14 +121,12 @@ class SessionLiveStatusSourceTest { @Test fun `HermesSessionLiveStatusSource fetches and decodes snapshot via RPC`() = runBlocking { - var capturedMethod: String? = null - var capturedParams: Map? = null + var rpcCalls = 0 val source = HermesSessionLiveStatusSource( - rpcRequest = { method, params -> - capturedMethod = method - capturedParams = params + rpcRequest = { + rpcCalls++ mapOf( "sessions" to listOf( @@ -146,8 +144,8 @@ class SessionLiveStatusSourceTest { val snapshot = source.fetchActiveSessionsSnapshot() - assertEquals(WsMethods.SESSION_ACTIVE_LIST, capturedMethod) - assertTrue(capturedParams!!.isEmpty()) + // Method name and empty params are pinned on the wire in HermesWsClientApprovalsTest. + assertEquals(1, rpcCalls) assertNotNull(snapshot) assertEquals(SessionLiveStatus.WORKING, snapshot!!.statusByStoredId["st-1"]) } @@ -157,7 +155,7 @@ class SessionLiveStatusSourceTest { runBlocking { val source = HermesSessionLiveStatusSource( - rpcRequest = { _, _ -> throw RuntimeException("RPC error") }, + rpcRequest = { throw RuntimeException("RPC error") }, eventsProvider = { MutableSharedFlow() }, connectionStatusProvider = { MutableStateFlow(ConnectionStatus.CONNECTED) }, ) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ApprovalsTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ApprovalsTypesTest.kt new file mode 100644 index 00000000..2b419264 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ApprovalsTypesTest.kt @@ -0,0 +1,209 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ApprovalsTypesTest { + @Test + fun approvalPendingParamsEncodesSessionIdExactly() { + val params = ApprovalPendingParams(sessionId = "sess-appr-pending-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalPendingParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-appr-pending-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun approvalReceivedParamsEncodesSessionIdAndRequestId() { + val params = + ApprovalReceivedParams( + sessionId = "sess-appr-rec-1", + requestId = "req-appr-rec-99", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalReceivedParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-appr-rec-1") + put("request_id", "req-appr-rec-99") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "request_id"), (encoded as JsonObject).keys) + } + + @Test + fun approvalRespondParamsEncodesOnlySessionIdWhenOptionalsNull() { + val params = ApprovalRespondParams(sessionId = "sess-respond-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-respond-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun approvalRespondParamsEncodesAllFieldsWhenPresent() { + val params = + ApprovalRespondParams( + sessionId = "sess-respond-2", + choice = "allow", + all = true, + requestId = "req-respond-2", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-respond-2") + put("choice", "allow") + put("all", true) + put("request_id", "req-respond-2") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "choice", "all", "request_id"), (encoded as JsonObject).keys) + } + + @Test + fun approvalRespondParamsEncodesExplicitFalseForAll() { + val params = + ApprovalRespondParams( + sessionId = "sess-respond-3", + choice = "deny", + all = false, + requestId = "req-respond-3", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-respond-3") + put("choice", "deny") + put("all", false) + put("request_id", "req-respond-3") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "choice", "all", "request_id"), (encoded as JsonObject).keys) + } + + @Test + fun approvalRespondParamsOmitsOnlyNullFields() { + val params = + ApprovalRespondParams( + sessionId = "sess-respond-4", + choice = "allow", + all = null, + requestId = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-respond-4") + put("choice", "allow") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "choice"), (encoded as JsonObject).keys) + } + + @Test + fun sessionActiveListParamsEncodesToEmptyJsonObjectWhenCurrentSessionIdNull() { + val params = SessionActiveListParams() + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionActiveListParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun sessionActiveListParamsEncodesCurrentSessionIdWhenSet() { + val params = SessionActiveListParams(currentSessionId = "sess-active-curr-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionActiveListParams.serializer(), params) + + val expected = + buildJsonObject { + put("current_session_id", "sess-active-curr-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("current_session_id"), (encoded as JsonObject).keys) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("approval.pending", RpcMethods.APPROVAL_PENDING.name) + assertEquals("approval.received", RpcMethods.APPROVAL_RECEIVED.name) + assertEquals("approval.respond", RpcMethods.APPROVAL_RESPOND.name) + assertEquals("session.active_list", RpcMethods.SESSION_ACTIVE_LIST.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.APPROVAL_PENDING)) + assertTrue(RpcMethods.all.contains(RpcMethods.APPROVAL_RECEIVED)) + assertTrue(RpcMethods.all.contains(RpcMethods.APPROVAL_RESPOND)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_ACTIVE_LIST)) + } + + @Test + fun jsonElementPassthroughMethodsDecodeResultUntouched() { + val arbitraryPayload = + buildJsonObject { + put("status", "pending") + put("count", 3) + put("nested", buildJsonObject { put("detail", "value") }) + } + + val decodedPending: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.APPROVAL_PENDING.result, + arbitraryPayload, + ) + val decodedReceived: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.APPROVAL_RECEIVED.result, + arbitraryPayload, + ) + val decodedRespond: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.APPROVAL_RESPOND.result, + arbitraryPayload, + ) + val decodedActiveList: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_ACTIVE_LIST.result, + arbitraryPayload, + ) + + assertEquals(arbitraryPayload, decodedPending) + assertEquals(arbitraryPayload, decodedReceived) + assertEquals(arbitraryPayload, decodedRespond) + assertEquals(arbitraryPayload, decodedActiveList) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt index bc797046..ec98fa19 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt @@ -1,7 +1,10 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.RpcMethod +import com.m57.hermescontrol.data.ws.contract.TypedRpcSender import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher @@ -9,6 +12,8 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals @@ -25,7 +30,7 @@ class ChatApprovalsDelegateTest { private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "storage-session-1")) private var runtimeId: String? = null private val sentMethods = mutableListOf() - private val sentParams = mutableListOf>() + private val sentParams = mutableListOf() private val trackedRequests = mutableListOf>() private val systemMessages = mutableListOf() @@ -35,11 +40,22 @@ class ChatApprovalsDelegateTest { ioDispatcher = testDispatcher, uiState = uiState, runtimeSessionId = { runtimeId }, - wsSend = { method, params, onSent -> - sentMethods.add(method) - sentParams.add(params) - onSent?.invoke("req-id-${sentMethods.size}") - }, + rpc = + object : TypedRpcSender { + override fun

send( + method: RpcMethod, + params: P, + onSent: ((String) -> Unit)?, + ): String { + sentMethods.add(method.name) + sentParams.add( + OkHttpProvider.json.encodeToJsonElement(method.params, params) as JsonObject, + ) + val id = "req-id-${sentMethods.size}" + onSent?.invoke(id) + return id + } + }, trackRequest = { id, method -> trackedRequests.add(id to method) }, addSystemMessage = { text -> systemMessages.add(text) }, ) @@ -98,8 +114,8 @@ class ChatApprovalsDelegateTest { assertFalse(uiState.value.isAgentTyping) assertEquals(listOf(WsMethods.APPROVAL_RECEIVED), sentMethods) - assertEquals("runtime-session-xyz", sentParams.first()["session_id"]) - assertEquals("req-123", sentParams.first()["request_id"]) + assertEquals(JsonPrimitive("runtime-session-xyz"), sentParams.first()["session_id"]) + assertEquals(JsonPrimitive("req-123"), sentParams.first()["request_id"]) } @Test @@ -125,11 +141,11 @@ class ChatApprovalsDelegateTest { advanceUntilIdle() assertEquals(listOf(WsMethods.APPROVAL_RESPOND, WsMethods.APPROVAL_PENDING), sentMethods) - assertEquals("once", sentParams[0]["choice"]) - assertEquals("req-999", sentParams[0]["request_id"]) - assertEquals(false, sentParams[0]["all"]) - assertEquals("runtime-session-xyz", sentParams[0]["session_id"]) - assertEquals("runtime-session-xyz", sentParams[1]["session_id"]) + assertEquals(JsonPrimitive("once"), sentParams[0]["choice"]) + assertEquals(JsonPrimitive("req-999"), sentParams[0]["request_id"]) + assertEquals(JsonPrimitive(false), sentParams[0]["all"]) + assertEquals(JsonPrimitive("runtime-session-xyz"), sentParams[0]["session_id"]) + assertEquals(JsonPrimitive("runtime-session-xyz"), sentParams[1]["session_id"]) } @Test @@ -270,7 +286,14 @@ class ChatApprovalsDelegateTest { ioDispatcher = testDispatcher, uiState = uiState, runtimeSessionId = { "session-1" }, - wsSend = { _, _, _ -> error("new approval must not use legacy response") }, + rpc = + object : TypedRpcSender { + override fun

send( + method: RpcMethod, + params: P, + onSent: ((String) -> Unit)?, + ): String = error("new approval must not use legacy response") + }, trackRequest = { _, _ -> }, addSystemMessage = { }, respondToServerRequest = { id, result -> responses += id to result }, 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 e69f29a0..a2a0be4b 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 @@ -6619,9 +6619,9 @@ class ChatViewModelTest { HermesWsClient.send( WsMethods.APPROVAL_RESPOND, withArg { params -> - assertEquals(sessionId, params["session_id"]) - assertEquals("once", params["choice"]) - assertEquals("req-42", params["request_id"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) + assertEquals(JsonPrimitive("once"), params["choice"]) + assertEquals(JsonPrimitive("req-42"), params["request_id"]) }, any(), ) @@ -6653,8 +6653,8 @@ class ChatViewModelTest { HermesWsClient.send( WsMethods.APPROVAL_RESPOND, withArg { params -> - assertEquals("session", params["choice"]) - assertEquals("req-7", params["request_id"]) + assertEquals(JsonPrimitive("session"), params["choice"]) + assertEquals(JsonPrimitive("req-7"), params["request_id"]) }, any(), ) @@ -6684,7 +6684,7 @@ class ChatViewModelTest { HermesWsClient.send( WsMethods.APPROVAL_RESPOND, withArg { params -> - assertEquals("deny", params["choice"]) + assertEquals(JsonPrimitive("deny"), params["choice"]) assertFalse(params.containsKey("request_id")) }, any(), @@ -6717,7 +6717,7 @@ class ChatViewModelTest { HermesWsClient.send( WsMethods.APPROVAL_PENDING, withArg { params -> - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) }, any(), ) @@ -6763,8 +6763,8 @@ class ChatViewModelTest { HermesWsClient.send( WsMethods.APPROVAL_RECEIVED, withArg { params -> - assertEquals("runtime-sid-999", params["session_id"]) - assertEquals("req-runtime-test", params["request_id"]) + assertEquals(JsonPrimitive("runtime-sid-999"), params["session_id"]) + assertEquals(JsonPrimitive("req-runtime-test"), params["request_id"]) }, any(), ) @@ -6777,16 +6777,16 @@ class ChatViewModelTest { HermesWsClient.send( WsMethods.APPROVAL_RESPOND, withArg { params -> - assertEquals("runtime-sid-999", params["session_id"]) - assertEquals("once", params["choice"]) - assertEquals("req-runtime-test", params["request_id"]) + assertEquals(JsonPrimitive("runtime-sid-999"), params["session_id"]) + assertEquals(JsonPrimitive("once"), params["choice"]) + assertEquals(JsonPrimitive("req-runtime-test"), params["request_id"]) }, any(), ) HermesWsClient.send( WsMethods.APPROVAL_PENDING, withArg { params -> - assertEquals("runtime-sid-999", params["session_id"]) + assertEquals(JsonPrimitive("runtime-sid-999"), params["session_id"]) }, any(), ) From 60c13f10cef1fbd3167ec3322652b466296ce255 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:21:14 +0300 Subject: [PATCH 071/107] refactor(#1375): typed params for subscription.*, usage.bars and vault.* (#1386) --- .../data/ws/BillingRepository.kt | 44 +- .../hermescontrol/data/ws/VaultRepository.kt | 31 +- .../data/ws/contract/BillingVault.kt | 40 ++ .../data/ws/contract/RpcMethod.kt | 89 ++++ .../data/ws/HermesWsClientBillingVaultTest.kt | 431 ++++++++++++++++++ .../data/ws/RawRpcParamsRatchetTest.kt | 11 - .../data/ws/contract/BillingVaultTypesTest.kt | 333 ++++++++++++++ 7 files changed, 933 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/BillingVault.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientBillingVaultTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/BillingVaultTypesTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt index 277162e6..8f6a602d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt @@ -8,6 +8,11 @@ import com.m57.hermescontrol.data.model.SubscriptionStateResponse import com.m57.hermescontrol.data.model.SubscriptionUpgradeResponse import com.m57.hermescontrol.data.model.UsageBarsResponse import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.ws.contract.EmptyParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SubscriptionChangeParams +import com.m57.hermescontrol.data.ws.contract.SubscriptionPreviewParams +import com.m57.hermescontrol.data.ws.contract.SubscriptionUpgradeParams import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull @@ -90,50 +95,47 @@ object BillingRepository { } suspend fun getSubscriptionState(): SubscriptionStateResponse? { - val result = HermesWsClient.request(WsMethods.SUBSCRIPTION_STATE).await() + val result = HermesWsClient.call(RpcMethods.SUBSCRIPTION_STATE, EmptyParams) return decode(result) } suspend fun getUsageBars(): UsageBarsResponse? { - val result = HermesWsClient.request(WsMethods.USAGE_BARS).await() + val result = HermesWsClient.call(RpcMethods.USAGE_BARS, EmptyParams) return decode(result) } suspend fun previewSubscription(subscriptionTypeId: String): SubscriptionPreviewResponse? { val result = - HermesWsClient - .request( - WsMethods.SUBSCRIPTION_PREVIEW, - mapOf("subscription_type_id" to subscriptionTypeId), - ).await() + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_PREVIEW, + SubscriptionPreviewParams(subscriptionTypeId = subscriptionTypeId), + ) return decode(result) } suspend fun changeSubscription(request: SubscriptionChangeRequest): SubscriptionChangeResponse? { val result = - HermesWsClient - .request( - WsMethods.SUBSCRIPTION_CHANGE, - buildMap { - request.subscription_type_id?.let { put("subscription_type_id", it) } - request.cancel?.let { put("cancel", it) } - }, - ).await() + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_CHANGE, + SubscriptionChangeParams( + subscriptionTypeId = request.subscription_type_id, + cancel = request.cancel, + ), + ) return decode(result) } suspend fun resumeSubscription(): SubscriptionResumeResponse? { - val result = HermesWsClient.request(WsMethods.SUBSCRIPTION_RESUME).await() + val result = HermesWsClient.call(RpcMethods.SUBSCRIPTION_RESUME, EmptyParams) return decode(result) } suspend fun upgradeSubscription(subscriptionTypeId: String): SubscriptionUpgradeResponse? { val result = - HermesWsClient - .request( - WsMethods.SUBSCRIPTION_UPGRADE, - mapOf("subscription_type_id" to subscriptionTypeId), - ).await() + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_UPGRADE, + SubscriptionUpgradeParams(subscriptionTypeId = subscriptionTypeId), + ) return decode(result) } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt index 56d7a6cc..234fece1 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt @@ -8,6 +8,11 @@ import com.m57.hermescontrol.data.model.VaultSourceSetResponse import com.m57.hermescontrol.data.model.VaultSourcesResponse import com.m57.hermescontrol.data.model.VaultUnlockResponse import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.ws.contract.EmptyParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.VaultLockParams +import com.m57.hermescontrol.data.ws.contract.VaultSourceSetParams +import com.m57.hermescontrol.data.ws.contract.VaultUnlockParams import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull @@ -48,7 +53,7 @@ object VaultRepository { } suspend fun getSources(): List { - val result = HermesWsClient.request(WsMethods.VAULT_SOURCES).await() + val result = HermesWsClient.call(RpcMethods.VAULT_SOURCES, EmptyParams) return decode(result)?.sources ?: emptyList() } @@ -57,11 +62,10 @@ object VaultRepository { enabled: Boolean, ): Boolean { val result = - HermesWsClient - .request( - WsMethods.VAULT_SOURCE_SET, - mapOf("name" to name, "enabled" to enabled), - ).await() + HermesWsClient.call( + RpcMethods.VAULT_SOURCE_SET, + VaultSourceSetParams(name = name, enabled = enabled), + ) return decode(result)?.enabled == enabled } @@ -70,22 +74,21 @@ object VaultRepository { password: String, ): Boolean { val result = - HermesWsClient - .request( - WsMethods.VAULT_UNLOCK, - mapOf("name" to name, "password" to password), - ).await() + HermesWsClient.call( + RpcMethods.VAULT_UNLOCK, + VaultUnlockParams(name = name, password = password), + ) return decode(result)?.unlocked == true } suspend fun lockSource(name: String? = null): Boolean { - val params = if (name != null) mapOf("name" to name) else emptyMap() - val result = HermesWsClient.request(WsMethods.VAULT_LOCK, params).await() + val params = VaultLockParams(name = name) + val result = HermesWsClient.call(RpcMethods.VAULT_LOCK, params) return decode(result)?.locked == true } suspend fun listItems(): List { - val result = HermesWsClient.request(WsMethods.VAULT_LIST).await() + val result = HermesWsClient.call(RpcMethods.VAULT_LIST, EmptyParams) return decode(result)?.items ?: emptyList() } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/BillingVault.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/BillingVault.kt new file mode 100644 index 00000000..28ae5871 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/BillingVault.kt @@ -0,0 +1,40 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data object EmptyParams + +@Serializable +data class SubscriptionPreviewParams( + @SerialName("subscription_type_id") val subscriptionTypeId: String? = null, +) + +@Serializable +data class SubscriptionChangeParams( + @SerialName("subscription_type_id") val subscriptionTypeId: String? = null, + @SerialName("cancel") val cancel: Boolean? = null, +) + +@Serializable +data class SubscriptionUpgradeParams( + @SerialName("subscription_type_id") val subscriptionTypeId: String? = null, +) + +@Serializable +data class VaultSourceSetParams( + @SerialName("name") val name: String? = null, + @SerialName("enabled") val enabled: Boolean? = null, +) + +@Serializable +data class VaultUnlockParams( + @SerialName("name") val name: String? = null, + @SerialName("password") val password: String? = null, +) + +@Serializable +data class VaultLockParams( + @SerialName("name") val name: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index 3d817fce..80722210 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -150,6 +150,84 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val SUBSCRIPTION_STATE: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_STATE, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val SUBSCRIPTION_PREVIEW: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_PREVIEW, + SubscriptionPreviewParams.serializer(), + JsonElement.serializer(), + ) + + val SUBSCRIPTION_CHANGE: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_CHANGE, + SubscriptionChangeParams.serializer(), + JsonElement.serializer(), + ) + + val SUBSCRIPTION_UPGRADE: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_UPGRADE, + SubscriptionUpgradeParams.serializer(), + JsonElement.serializer(), + ) + + val SUBSCRIPTION_RESUME: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_RESUME, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val USAGE_BARS: RpcMethod = + RpcMethod( + WsMethods.USAGE_BARS, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_LIST: RpcMethod = + RpcMethod( + WsMethods.VAULT_LIST, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_SOURCES: RpcMethod = + RpcMethod( + WsMethods.VAULT_SOURCES, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_SOURCE_SET: RpcMethod = + RpcMethod( + WsMethods.VAULT_SOURCE_SET, + VaultSourceSetParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_UNLOCK: RpcMethod = + RpcMethod( + WsMethods.VAULT_UNLOCK, + VaultUnlockParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_LOCK: RpcMethod = + RpcMethod( + WsMethods.VAULT_LOCK, + VaultLockParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -171,5 +249,16 @@ object RpcMethods { APPROVAL_RECEIVED, APPROVAL_RESPOND, SESSION_ACTIVE_LIST, + SUBSCRIPTION_STATE, + SUBSCRIPTION_PREVIEW, + SUBSCRIPTION_CHANGE, + SUBSCRIPTION_UPGRADE, + SUBSCRIPTION_RESUME, + USAGE_BARS, + VAULT_LIST, + VAULT_SOURCES, + VAULT_SOURCE_SET, + VAULT_UNLOCK, + VAULT_LOCK, ) } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientBillingVaultTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientBillingVaultTest.kt new file mode 100644 index 00000000..fc804bf2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientBillingVaultTest.kt @@ -0,0 +1,431 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.EmptyParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SubscriptionChangeParams +import com.m57.hermescontrol.data.ws.contract.VaultLockParams +import com.m57.hermescontrol.data.ws.contract.VaultUnlockParams +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientBillingVaultTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeoutOrNull(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } + } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun typedCallSubscriptionChangeEncodesOnlyNonNullFieldsAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("status", "ok") + put("subscription_id", "sub-12345") + put("scheduled_cancellation", false) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "subscription.change") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_CHANGE, + SubscriptionChangeParams( + subscriptionTypeId = null, + cancel = false, + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("subscription.change", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put("cancel", false) + } + assertEquals(expectedParams, params) + assertEquals(false, (params?.get("cancel") as? JsonPrimitive)?.booleanOrNull) + assertFalse(params?.containsKey("subscription_type_id") ?: true) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallVaultUnlockPutsNameAndPasswordOnWireAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("unlocked", true) + put("expires_in", 3600) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "vault.unlock") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.VAULT_UNLOCK, + VaultUnlockParams( + name = "op-work", + password = "super-secret-pass", + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("vault.unlock", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put("name", "op-work") + put("password", "super-secret-pass") + } + assertEquals(expectedParams, params) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallVaultLockWithNamePutsNameOnWireAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("locked", true) + put("vault", "op-work") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "vault.lock") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.VAULT_LOCK, + VaultLockParams(name = "op-work"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("vault.lock", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put("name", "op-work") + } + assertEquals(expectedParams, params) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallVaultLockWithoutNameLeavesParamsAbsentOrEmptyAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("locked_all", true) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "vault.lock") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.VAULT_LOCK, + VaultLockParams(name = null), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("vault.lock", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertTrue( + "vault.lock with null name carries no params", + params == null || params.isEmpty(), + ) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallEmptyParamsMethodLeavesParamsAbsentOrEmptyAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("status", "active") + put("tier", "standard") + put("valid_until", "2026-12-31T23:59:59Z") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "subscription.state") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_STATE, + EmptyParams, + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("subscription.state", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertTrue( + "subscription.state with EmptyParams carries no params", + params == null || params.isEmpty(), + ) + assertEquals(serverResultJson, result) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index 9e7a02d1..dae2bca2 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -325,12 +325,6 @@ class RawRpcParamsRatchetTest { "data/ws/AccountConnectorRepository.kt::CONNECTORS_POLICY_GET", "data/ws/AccountConnectorRepository.kt::CONNECTORS_POLICY_SET", "data/ws/AccountConnectorRepository.kt::CONNECTORS_TOOLS", - "data/ws/BillingRepository.kt::SUBSCRIPTION_CHANGE", - "data/ws/BillingRepository.kt::SUBSCRIPTION_PREVIEW", - "data/ws/BillingRepository.kt::SUBSCRIPTION_RESUME", - "data/ws/BillingRepository.kt::SUBSCRIPTION_STATE", - "data/ws/BillingRepository.kt::SUBSCRIPTION_UPGRADE", - "data/ws/BillingRepository.kt::USAGE_BARS", "data/ws/ConnectorRepository.kt::CONNECTORS_CONNECT", "data/ws/ConnectorRepository.kt::CONNECTORS_LIST", "data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES", @@ -341,11 +335,6 @@ class RawRpcParamsRatchetTest { "data/ws/ProjectsSource.kt::PROJECTS_LIST", "data/ws/SubagentRepository.kt::SUBAGENT_LIST", "data/ws/SubagentRepository.kt::SUBAGENT_TAIL", - "data/ws/VaultRepository.kt::VAULT_LIST", - "data/ws/VaultRepository.kt::VAULT_LOCK", - "data/ws/VaultRepository.kt::VAULT_SOURCES", - "data/ws/VaultRepository.kt::VAULT_SOURCE_SET", - "data/ws/VaultRepository.kt::VAULT_UNLOCK", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/BillingVaultTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/BillingVaultTypesTest.kt new file mode 100644 index 00000000..46adba43 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/BillingVaultTypesTest.kt @@ -0,0 +1,333 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BillingVaultTypesTest { + @Test + fun emptyParamsEncodesToEmptyJsonObject() { + val params = EmptyParams + val encoded = OkHttpProvider.json.encodeToJsonElement(EmptyParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun subscriptionPreviewParamsEncodesSubscriptionTypeIdWhenPresent() { + val params = SubscriptionPreviewParams(subscriptionTypeId = "pro-monthly") + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionPreviewParams.serializer(), params) + + val expected = + buildJsonObject { + put("subscription_type_id", "pro-monthly") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("subscription_type_id"), (encoded as JsonObject).keys) + } + + @Test + fun subscriptionPreviewParamsOmitsNullSubscriptionTypeId() { + val params = SubscriptionPreviewParams(subscriptionTypeId = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionPreviewParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun subscriptionChangeParamsEncodesAllFieldsWhenPresent() { + val params = + SubscriptionChangeParams( + subscriptionTypeId = "tier-2", + cancel = true, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), params) + + val expected = + buildJsonObject { + put("subscription_type_id", "tier-2") + put("cancel", true) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("subscription_type_id", "cancel"), (encoded as JsonObject).keys) + } + + @Test + fun subscriptionChangeParamsEncodesExplicitFalseForCancel() { + val params = + SubscriptionChangeParams( + subscriptionTypeId = "tier-1", + cancel = false, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), params) + + val expected = + buildJsonObject { + put("subscription_type_id", "tier-1") + put("cancel", false) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("subscription_type_id", "cancel"), (encoded as JsonObject).keys) + } + + @Test + fun subscriptionChangeParamsOmitsOnlyNullFields() { + val paramsOnlyCancel = SubscriptionChangeParams(subscriptionTypeId = null, cancel = false) + val encodedOnlyCancel = + OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsOnlyCancel) + val expectedOnlyCancel = + buildJsonObject { + put("cancel", false) + } + assertEquals(expectedOnlyCancel, encodedOnlyCancel) + assertTrue(encodedOnlyCancel is JsonObject) + assertEquals(setOf("cancel"), (encodedOnlyCancel as JsonObject).keys) + + val paramsOnlyType = SubscriptionChangeParams(subscriptionTypeId = "tier-3", cancel = null) + val encodedOnlyType = + OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsOnlyType) + val expectedOnlyType = + buildJsonObject { + put("subscription_type_id", "tier-3") + } + assertEquals(expectedOnlyType, encodedOnlyType) + assertTrue(encodedOnlyType is JsonObject) + assertEquals(setOf("subscription_type_id"), (encodedOnlyType as JsonObject).keys) + + val paramsEmpty = SubscriptionChangeParams(subscriptionTypeId = null, cancel = null) + val encodedEmpty = + OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsEmpty) + val expectedEmpty = buildJsonObject {} + assertEquals(expectedEmpty, encodedEmpty) + assertTrue(encodedEmpty is JsonObject) + assertTrue((encodedEmpty as JsonObject).isEmpty()) + } + + @Test + fun subscriptionUpgradeParamsEncodesSubscriptionTypeIdWhenPresent() { + val params = SubscriptionUpgradeParams(subscriptionTypeId = "enterprise-yearly") + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionUpgradeParams.serializer(), params) + + val expected = + buildJsonObject { + put("subscription_type_id", "enterprise-yearly") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("subscription_type_id"), (encoded as JsonObject).keys) + } + + @Test + fun subscriptionUpgradeParamsOmitsNullSubscriptionTypeId() { + val params = SubscriptionUpgradeParams(subscriptionTypeId = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionUpgradeParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun vaultSourceSetParamsEncodesAllFieldsWhenPresent() { + val params = + VaultSourceSetParams( + name = "1password", + enabled = true, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "1password") + put("enabled", true) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name", "enabled"), (encoded as JsonObject).keys) + } + + @Test + fun vaultSourceSetParamsEncodesExplicitFalseForEnabled() { + val params = + VaultSourceSetParams( + name = "bitwarden", + enabled = false, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "bitwarden") + put("enabled", false) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name", "enabled"), (encoded as JsonObject).keys) + } + + @Test + fun vaultSourceSetParamsOmitsNullFields() { + val params = VaultSourceSetParams(name = "keepass", enabled = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "keepass") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name"), (encoded as JsonObject).keys) + + val paramsEmpty = VaultSourceSetParams(name = null, enabled = null) + val encodedEmpty = + OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), paramsEmpty) + val expectedEmpty = buildJsonObject {} + assertEquals(expectedEmpty, encodedEmpty) + assertTrue(encodedEmpty is JsonObject) + assertTrue((encodedEmpty as JsonObject).isEmpty()) + } + + @Test + fun vaultUnlockParamsEncodesAllFieldsWhenPresent() { + val params = + VaultUnlockParams( + name = "local-vault", + password = "secret-password", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "local-vault") + put("password", "secret-password") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name", "password"), (encoded as JsonObject).keys) + } + + @Test + fun vaultUnlockParamsOmitsNullFields() { + val params = VaultUnlockParams(name = "local-vault", password = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "local-vault") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name"), (encoded as JsonObject).keys) + + val paramsEmpty = VaultUnlockParams(name = null, password = null) + val encodedEmpty = + OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), paramsEmpty) + val expectedEmpty = buildJsonObject {} + assertEquals(expectedEmpty, encodedEmpty) + assertTrue(encodedEmpty is JsonObject) + assertTrue((encodedEmpty as JsonObject).isEmpty()) + } + + @Test + fun vaultLockParamsEncodesEmptyJsonObjectWhenNameNull() { + val params = VaultLockParams(name = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultLockParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun vaultLockParamsEncodesNameWhenSet() { + val params = VaultLockParams(name = "primary-vault") + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultLockParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "primary-vault") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name"), (encoded as JsonObject).keys) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("subscription.state", RpcMethods.SUBSCRIPTION_STATE.name) + assertEquals("usage.bars", RpcMethods.USAGE_BARS.name) + assertEquals("subscription.resume", RpcMethods.SUBSCRIPTION_RESUME.name) + assertEquals("vault.list", RpcMethods.VAULT_LIST.name) + assertEquals("vault.sources", RpcMethods.VAULT_SOURCES.name) + assertEquals("subscription.preview", RpcMethods.SUBSCRIPTION_PREVIEW.name) + assertEquals("subscription.change", RpcMethods.SUBSCRIPTION_CHANGE.name) + assertEquals("subscription.upgrade", RpcMethods.SUBSCRIPTION_UPGRADE.name) + assertEquals("vault.source.set", RpcMethods.VAULT_SOURCE_SET.name) + assertEquals("vault.unlock", RpcMethods.VAULT_UNLOCK.name) + assertEquals("vault.lock", RpcMethods.VAULT_LOCK.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_STATE)) + assertTrue(RpcMethods.all.contains(RpcMethods.USAGE_BARS)) + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_RESUME)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_LIST)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_SOURCES)) + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_PREVIEW)) + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_CHANGE)) + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_UPGRADE)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_SOURCE_SET)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_UNLOCK)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_LOCK)) + } + + @Test + fun jsonElementPassthroughMethodsDecodeResultUntouched() { + val arbitraryPayload = + buildJsonObject { + put("tier", "pro") + put("credits", 4200) + put("details", buildJsonObject { put("active", true) }) + } + + val methods = + listOf( + RpcMethods.SUBSCRIPTION_STATE, + RpcMethods.USAGE_BARS, + RpcMethods.SUBSCRIPTION_RESUME, + RpcMethods.VAULT_LIST, + RpcMethods.VAULT_SOURCES, + RpcMethods.SUBSCRIPTION_PREVIEW, + RpcMethods.SUBSCRIPTION_CHANGE, + RpcMethods.SUBSCRIPTION_UPGRADE, + RpcMethods.VAULT_SOURCE_SET, + RpcMethods.VAULT_UNLOCK, + RpcMethods.VAULT_LOCK, + ) + + for (method in methods) { + val decoded: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + method.result, + arbitraryPayload, + ) + assertEquals("Method ${method.name} must pass raw result untouched", arbitraryPayload, decoded) + } + } +} From 8ae2ddbbeed27b51fa27cd711a2c546233d90657 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:27:27 +0300 Subject: [PATCH 072/107] refactor(#1375): type connectors repository RPC params (#1387) --- .../data/ws/AccountConnectorRepository.kt | 115 ++-- .../data/ws/ConnectorRepository.kt | 34 +- .../data/ws/contract/Connectors.kt | 74 +++ .../data/ws/contract/RpcMethod.kt | 96 ++++ .../data/ws/AccountConnectorRepositoryTest.kt | 15 +- .../data/ws/ConnectorRepositoryTest.kt | 171 +++--- .../data/ws/FakeTypedRpcCaller.kt | 22 + .../data/ws/HermesWsClientConnectorsTest.kt | 358 ++++++++++++ .../data/ws/RawRpcParamsRatchetTest.kt | 11 - .../data/ws/contract/ConnectorsTypesTest.kt | 539 ++++++++++++++++++ 10 files changed, 1286 insertions(+), 149 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/Connectors.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/FakeTypedRpcCaller.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectorsTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectorsTypesTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt index 9a5fee1d..4c6a4042 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt @@ -7,6 +7,19 @@ import com.m57.hermescontrol.data.model.ConnectorCatalogEntry import com.m57.hermescontrol.data.model.ConnectorError import com.m57.hermescontrol.data.model.ConnectorPolicy import com.m57.hermescontrol.data.model.ConnectorTool +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsRemoveParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsCatalogParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicyGetParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicySetParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsToolsParams +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.coroutines.CancellationException import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -74,6 +87,7 @@ class HermesAccountConnectorRepository( if (!deferred.isCompleted) deferred.cancel() } }, + private val caller: TypedRpcCaller = HermesRpcCaller, ) : AccountConnectorRepository { private suspend fun request( method: String, @@ -116,10 +130,11 @@ class HermesAccountConnectorRepository( override suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult = try { ConnectorParser.parseListResult( - rpc( - WsMethods.CONNECTORS_LIST, - accountParams( - mapOf("owner" to mapOf("type" to "account")), + caller.call( + RpcMethods.CONNECTORS_LIST, + ConnectorsListParams( + owner = ConnectorOwner.account(), + profile = AuthManager.activeProfileId.value, ), ), ) @@ -146,14 +161,13 @@ class HermesAccountConnectorRepository( ) = guarded { require(slugs.isNotEmpty() && slugs.all(ConnectorRepository::isValidSlug)) parseOperation( - request( - WsMethods.CONNECTORS_CONNECT, - accountParams( - mapOf( - "owner" to mapOf("type" to "account"), - "connectors" to slugs, - "reconnect" to reconnect, - ), + caller.call( + RpcMethods.CONNECTORS_CONNECT, + ConnectorsConnectParams( + owner = ConnectorOwner.account(), + connectors = slugs, + reconnect = reconnect, + profile = AuthManager.activeProfileId.value, ), ), ) @@ -166,7 +180,16 @@ class HermesAccountConnectorRepository( override suspend fun operationStatus(opId: String) = guarded { - parseOperation(operationRequest(WsMethods.CONNECTORS_OPERATION_STATUS, mapOf("op_id" to opId))) + parseOperation( + caller.call( + RpcMethods.CONNECTORS_OPERATION_STATUS, + ConnectorsOperationStatusParams( + owner = ConnectorOwner.account(), + opId = opId, + profile = AuthManager.activeProfileId.value, + ), + ), + ) } @Suppress("UNCHECKED_CAST") @@ -180,7 +203,14 @@ class HermesAccountConnectorRepository( override suspend fun catalog() = guarded { - obj(request(WsMethods.CONNECTORS_CATALOG, accountParams()))["connectors"]!!.jsonArray.map { + val response = + caller.call( + RpcMethods.CONNECTORS_CATALOG, + ConnectorsCatalogParams( + profile = AuthManager.activeProfileId.value, + ), + ) + obj(response)["connectors"]!!.jsonArray.map { val row = it.jsonObject ConnectorCatalogEntry(row.str("slug"), row.str("name"), row.str("description"), row.str("category")) } @@ -188,7 +218,14 @@ class HermesAccountConnectorRepository( override suspend fun accounts() = guarded { - obj(request(WsMethods.CONNECTORS_ACCOUNTS, accountParams()))["accounts"]!!.jsonArray.map { e -> + val response = + caller.call( + RpcMethods.CONNECTORS_ACCOUNTS, + ConnectorsAccountsParams( + profile = AuthManager.activeProfileId.value, + ), + ) + obj(response)["accounts"]!!.jsonArray.map { e -> val r = e.jsonObject ConnectorAccount( r.str("connection_id"), @@ -209,10 +246,11 @@ class HermesAccountConnectorRepository( require(connectionId.isNotBlank()) val response = obj( - request( - WsMethods.CONNECTORS_ACCOUNTS_REMOVE, - accountParams( - mapOf("connection_id" to connectionId), + caller.call( + RpcMethods.CONNECTORS_ACCOUNTS_REMOVE, + ConnectorsAccountsRemoveParams( + connectionId = connectionId, + profile = AuthManager.activeProfileId.value, ), ), ) @@ -225,12 +263,16 @@ class HermesAccountConnectorRepository( refresh: Boolean, ) = guarded { require(ConnectorRepository.isValidSlug(slug)) - val params = - buildMap { - put("slug", slug) - if (refresh) put("refresh", true) - } - obj(request(WsMethods.CONNECTORS_TOOLS, accountParams(params)))["tools"]!!.jsonArray.map { e -> + val response = + caller.call( + RpcMethods.CONNECTORS_TOOLS, + ConnectorsToolsParams( + slug = slug, + refresh = if (refresh) true else null, + profile = AuthManager.activeProfileId.value, + ), + ) + obj(response)["tools"]!!.jsonArray.map { e -> val r = e.jsonObject ConnectorTool( r.str("slug"), @@ -245,7 +287,15 @@ class HermesAccountConnectorRepository( override suspend fun policy() = guarded { - val root = obj(request(WsMethods.CONNECTORS_POLICY_GET, accountParams())) + val root = + obj( + caller.call( + RpcMethods.CONNECTORS_POLICY_GET, + ConnectorsPolicyGetParams( + profile = AuthManager.activeProfileId.value, + ), + ), + ) val layers = root.array("layers").map { it.jsonObject } val member = layers.firstOrNull { it.str("kind") == "member" } parsePolicy(root["effective"]!!.jsonObject).copy( @@ -293,13 +343,12 @@ class HermesAccountConnectorRepository( check(scope == AuthManager.currentDataScope()) { "Account scope changed" } val result = obj( - request( - WsMethods.CONNECTORS_POLICY_SET, - accountParams( - mapOf( - "change" to change, - "expected_revision" to expectedRevision, - ), + caller.call( + RpcMethods.CONNECTORS_POLICY_SET, + ConnectorsPolicySetParams( + change = ConnectorParser.toJsonElement(change) as JsonObject, + expectedRevision = expectedRevision, + profile = AuthManager.activeProfileId.value, ), ), ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt index a6535881..70816e73 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt @@ -3,6 +3,12 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.model.ConnectorConnectResult import com.m57.hermescontrol.data.model.ConnectorError import com.m57.hermescontrol.data.model.ConnectorListResult +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.coroutines.CancellationException /** @@ -42,19 +48,7 @@ interface ConnectorRepository { * Default implementation of [ConnectorRepository] backed by [HermesWsClient]. */ class HermesConnectorRepository( - private val rpcRequest: suspend (method: String, params: Map) -> Any? = { method, params -> - val deferred = HermesWsClient.request(method, params) - try { - deferred.await() - } catch (e: CancellationException) { - deferred.cancel(e) - throw e - } finally { - if (!deferred.isCompleted) { - deferred.cancel() - } - } - }, + private val caller: TypedRpcCaller = HermesRpcCaller, ) : ConnectorRepository { override suspend fun listConnectors(sessionId: String): ConnectorListResult { if (sessionId.isBlank()) { @@ -64,8 +58,8 @@ class HermesConnectorRepository( } return try { - val params = mapOf("owner" to ConnectorRepository.sessionOwner(sessionId)) - val result = rpcRequest(WsMethods.CONNECTORS_LIST, params) + val params = ConnectorsListParams(owner = ConnectorOwner.session(sessionId)) + val result = caller.call(RpcMethods.CONNECTORS_LIST, params) ConnectorParser.parseListResult(result) } catch (e: CancellationException) { throw e @@ -107,12 +101,12 @@ class HermesConnectorRepository( return try { val params = - mapOf( - "owner" to ConnectorRepository.sessionOwner(sessionId), - "connectors" to connectors, - "reconnect" to reconnect, + ConnectorsConnectParams( + owner = ConnectorOwner.session(sessionId), + connectors = connectors, + reconnect = reconnect, ) - val result = rpcRequest(WsMethods.CONNECTORS_CONNECT, params) + val result = caller.call(RpcMethods.CONNECTORS_CONNECT, params) ConnectorParser.parseConnectResult(result) } catch (e: CancellationException) { throw e diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Connectors.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Connectors.kt new file mode 100644 index 00000000..b00a2c5c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Connectors.kt @@ -0,0 +1,74 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class ConnectorOwner( + @SerialName("type") val type: String, + @SerialName("session_id") val sessionId: String? = null, +) { + companion object { + fun account() = ConnectorOwner("account") + + fun session(sessionId: String) = ConnectorOwner("session", sessionId) + } +} + +@Serializable +data class ConnectorsListParams( + @SerialName("owner") val owner: ConnectorOwner, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsConnectParams( + @SerialName("owner") val owner: ConnectorOwner, + @SerialName("connectors") val connectors: List, + @SerialName("reconnect") val reconnect: Boolean? = null, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsOperationStatusParams( + @SerialName("owner") val owner: ConnectorOwner, + @SerialName("op_id") val opId: String, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsCatalogParams( + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsAccountsParams( + @SerialName("connector") val connector: String? = null, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsAccountsRemoveParams( + @SerialName("connection_id") val connectionId: String, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsPolicyGetParams( + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsPolicySetParams( + @SerialName("change") val change: JsonObject, + @SerialName("expected_revision") val expectedRevision: String, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsToolsParams( + @SerialName("slug") val slug: String, + @SerialName("refresh") val refresh: Boolean? = null, + @SerialName("profile") val profile: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index 80722210..a123c18d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.ws.contract +import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsMethods import kotlinx.serialization.KSerializer import kotlinx.serialization.json.JsonElement @@ -18,6 +19,20 @@ interface TypedRpcSender { ): String } +interface TypedRpcCaller { + suspend fun call( + method: RpcMethod, + params: P, + ): R +} + +object HermesRpcCaller : TypedRpcCaller { + override suspend fun call( + method: RpcMethod, + params: P, + ): R = HermesWsClient.call(method, params) +} + object RpcMethods { val SESSION_EVENTS_SINCE: RpcMethod = RpcMethod( @@ -228,6 +243,78 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_LIST: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_LIST, + ConnectorsListParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_CONNECT: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_CONNECT, + ConnectorsConnectParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_OPERATION_STATUS: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_OPERATION_STATUS, + ConnectorsOperationStatusParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_CATALOG: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_CATALOG, + ConnectorsCatalogParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_ACCOUNTS: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_ACCOUNTS, + ConnectorsAccountsParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_ACCOUNTS_REMOVE: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_ACCOUNTS_REMOVE, + ConnectorsAccountsRemoveParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_POLICY_GET: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_POLICY_GET, + ConnectorsPolicyGetParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_POLICY_SET: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_POLICY_SET, + ConnectorsPolicySetParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_TOOLS: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_TOOLS, + ConnectorsToolsParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -260,5 +347,14 @@ object RpcMethods { VAULT_SOURCE_SET, VAULT_UNLOCK, VAULT_LOCK, + CONNECTORS_LIST, + CONNECTORS_CONNECT, + CONNECTORS_OPERATION_STATUS, + CONNECTORS_CATALOG, + CONNECTORS_ACCOUNTS, + CONNECTORS_ACCOUNTS_REMOVE, + CONNECTORS_POLICY_GET, + CONNECTORS_POLICY_SET, + CONNECTORS_TOOLS, ) } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt index c13f06b0..8db2b0f4 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt @@ -28,7 +28,7 @@ class AccountConnectorRepositoryTest { runTest { val calls = mutableListOf>>() val repo = - HermesAccountConnectorRepository { method, params -> + accountRepo { method, params -> calls += method to params operationFixture() } @@ -57,7 +57,7 @@ class AccountConnectorRepositoryTest { runTest { val calls = mutableListOf>>() val repo = - HermesAccountConnectorRepository { method, params -> + accountRepo { method, params -> calls += method to params operationFixture() } @@ -92,7 +92,7 @@ class AccountConnectorRepositoryTest { runTest { val calls = mutableListOf>>() val repo = - HermesAccountConnectorRepository { method, params -> + accountRepo { method, params -> calls += method to params when (method) { WsMethods.CONNECTORS_CATALOG -> { @@ -165,7 +165,7 @@ class AccountConnectorRepositoryTest { @Test fun `policy keeps member revision separate from effective and respects inherited rules`() = runTest { - val repo = HermesAccountConnectorRepository { _, _ -> policyFixture() } + val repo = accountRepo { _, _ -> policyFixture() } val policy = (repo.policy() as AccountConnectorResult.Success).value assertEquals("effective-revision", policy.revision) assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", policy.member?.revision) @@ -180,7 +180,7 @@ class AccountConnectorRepositoryTest { runTest { val calls = mutableListOf>>() val repo = - HermesAccountConnectorRepository { method, params -> + accountRepo { method, params -> calls += method to params if (method == WsMethods.CONNECTORS_POLICY_GET) { policyFixture() @@ -208,10 +208,13 @@ class AccountConnectorRepositoryTest { @Test fun `malformed authorization does not look like a successful connect`() = runTest { - val repo = HermesAccountConnectorRepository { _, _ -> mapOf("status" to "initiated") } + val repo = accountRepo { _, _ -> mapOf("status" to "initiated") } assertTrue(repo.connect(listOf("drive")) is AccountConnectorResult.Failure) } + private fun accountRepo(handler: suspend (String, Map) -> Any?) = + HermesAccountConnectorRepository(rpc = handler, caller = fakeCaller(handler)) + private fun operationFixture() = mapOf( "op_id" to "op-a", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt index 958bff9a..903da505 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt @@ -34,11 +34,12 @@ class ConnectorRepositoryTest { val repo = HermesConnectorRepository( - rpcRequest = { method, params -> - recordedMethod = method - recordedParams = params - mapOf("available" to true, "connectors" to emptyList>()) - }, + caller = + fakeCaller { method, params -> + recordedMethod = method + recordedParams = params + mapOf("available" to true, "connectors" to emptyList>()) + }, ) val result = repo.listConnectors("sess-abc-123") @@ -61,17 +62,18 @@ class ConnectorRepositoryTest { val repo = HermesConnectorRepository( - rpcRequest = { method, params -> - recordedMethod = method - recordedParams = params - mapOf( - "results" to - listOf( - mapOf("connector" to "linear", "status" to "initiated"), - ), - "summary" to mapOf("total" to 1, "initiated" to 1), - ) - }, + caller = + fakeCaller { method, params -> + recordedMethod = method + recordedParams = params + mapOf( + "results" to + listOf( + mapOf("connector" to "linear", "status" to "initiated"), + ), + "summary" to mapOf("total" to 1, "initiated" to 1), + ) + }, ) val result = @@ -103,16 +105,17 @@ class ConnectorRepositoryTest { val repo = HermesConnectorRepository( - rpcRequest = { _, params -> - recordedParams = params - mapOf( - "results" to - listOf( - mapOf("connector" to "slack", "status" to "active"), - ), - "summary" to mapOf("total" to 1, "active" to 1), - ) - }, + caller = + fakeCaller { _, params -> + recordedParams = params + mapOf( + "results" to + listOf( + mapOf("connector" to "slack", "status" to "active"), + ), + "summary" to mapOf("total" to 1, "active" to 1), + ) + }, ) repo.connect(sessionId = "sess-789", connectors = listOf("slack")) @@ -125,10 +128,11 @@ class ConnectorRepositoryTest { var rpcCalled = false val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - rpcCalled = true - null - }, + caller = + fakeCaller { _, _ -> + rpcCalled = true + null + }, ) val r1 = repo.listConnectors("") @@ -147,10 +151,11 @@ class ConnectorRepositoryTest { var rpcCalled = false val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - rpcCalled = true - null - }, + caller = + fakeCaller { _, _ -> + rpcCalled = true + null + }, ) // Blank session_id @@ -172,10 +177,11 @@ class ConnectorRepositoryTest { var rpcCalled = false val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - rpcCalled = true - null - }, + caller = + fakeCaller { _, _ -> + rpcCalled = true + null + }, ) // Invalid slugs: uppercase, starting with hyphen/underscore, containing spaces/symbols @@ -210,9 +216,10 @@ class ConnectorRepositoryTest { runBlocking { val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw CancellationException("coroutine cancelled") - }, + caller = + fakeCaller { _, _ -> + throw CancellationException("coroutine cancelled") + }, ) try { @@ -236,13 +243,14 @@ class ConnectorRepositoryTest { // 4031 CONNECTORS_UNAVAILABLE val repo4031 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Connectors unavailable", - code = 4031, - data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))), - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Connectors unavailable", + code = 4031, + data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))), + ) + }, ) val r4031 = repo4031.listConnectors("sess-1") assertTrue(r4031 is ConnectorListResult.Error) @@ -251,13 +259,14 @@ class ConnectorRepositoryTest { // 4001 NOT_OWNER val repo4001 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Session not owned", - code = 4001, - data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))), - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Session not owned", + code = 4001, + data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))), + ) + }, ) val r4001 = repo4001.connect("sess-1", listOf("linear")) assertTrue(r4001 is ConnectorConnectResult.Error) @@ -266,13 +275,14 @@ class ConnectorRepositoryTest { // 5033 UNSUPPORTED_RUNTIME val repo5033 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Compute host required", - code = 5033, - data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))), - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Compute host required", + code = 5033, + data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))), + ) + }, ) val r5033 = repo5033.connect("sess-1", listOf("linear")) assertTrue(r5033 is ConnectorConnectResult.Error) @@ -281,13 +291,14 @@ class ConnectorRepositoryTest { // 5034 INVALID_CONNECTOR_RESPONSE val repo5034 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Invalid connector response", - code = 5034, - data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))), - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Invalid connector response", + code = 5034, + data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))), + ) + }, ) val r5034 = repo5034.connect("sess-1", listOf("linear")) assertTrue(r5034 is ConnectorConnectResult.Error) @@ -296,13 +307,14 @@ class ConnectorRepositoryTest { // -32601 Unsupported backend val repo32601 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Method not found", - code = -32601, - data = null, - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Method not found", + code = -32601, + data = null, + ) + }, ) val r32601 = repo32601.listConnectors("sess-1") assertTrue(r32601 is ConnectorListResult.Error) @@ -314,9 +326,10 @@ class ConnectorRepositoryTest { runBlocking { val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw java.io.IOException("Connection reset by peer") - }, + caller = + fakeCaller { _, _ -> + throw java.io.IOException("Connection reset by peer") + }, ) val rList = repo.listConnectors("sess-1") diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/FakeTypedRpcCaller.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/FakeTypedRpcCaller.kt new file mode 100644 index 00000000..a133a003 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/FakeTypedRpcCaller.kt @@ -0,0 +1,22 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.ws.contract.RpcMethod +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller + +/** + * Test double for [TypedRpcCaller]: encodes the typed params exactly as the wire would, hands the + * resulting map to [handler] together with the method name, and returns its value as a JsonElement. + */ +internal fun fakeCaller(handler: suspend (String, Map) -> Any?): TypedRpcCaller = + object : TypedRpcCaller { + @Suppress("UNCHECKED_CAST") + override suspend fun call( + method: RpcMethod, + params: P, + ): R { + val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params) + val map = (encoded.toAny() as? Map).orEmpty() + return ConnectorParser.toJsonElement(handler(method.name, map)) as R + } + } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectorsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectorsTest.kt new file mode 100644 index 00000000..0e4009dc --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectorsTest.kt @@ -0,0 +1,358 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicySetParams +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientConnectorsTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeoutOrNull(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } + } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun typedCallConnectorsConnectEncodesOwnerAccountReconnectTrueAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("op_id", "op-connect-123") + put("status", "running") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connectors.connect") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.CONNECTORS_CONNECT, + ConnectorsConnectParams( + owner = ConnectorOwner.account(), + connectors = listOf("slack", "github"), + reconnect = true, + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("connectors.connect", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put( + "connectors", + buildJsonArray { + add(JsonPrimitive("slack")) + add(JsonPrimitive("github")) + }, + ) + put("reconnect", true) + } + assertEquals(expectedParams, params) + assertEquals(true, (params?.get("reconnect") as? JsonPrimitive)?.booleanOrNull) + assertFalse(params?.containsKey("profile") ?: true) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallConnectorsPolicySetEncodesNestedChangeWithArrayAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("revision", 5) + put("applied", true) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connectors.policy.set") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val nestedChange = + buildJsonObject { + put("policy", "whitelist") + put( + "allowed_connectors", + buildJsonArray { + add(JsonPrimitive("notion")) + add(JsonPrimitive("linear")) + }, + ) + put( + "rules", + buildJsonObject { + put("require_auth", true) + }, + ) + } + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.CONNECTORS_POLICY_SET, + ConnectorsPolicySetParams( + change = nestedChange, + expectedRevision = "4", + profile = "team-profile", + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("connectors.policy.set", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put("change", nestedChange) + put("expected_revision", "4") + put("profile", "team-profile") + } + assertEquals(expectedParams, params) + assertEquals(nestedChange, params?.get("change")) + assertEquals(serverResultJson, result) + } + + @Test + fun hermesRpcCallerCallDelegatesToHermesWsClientCallAndEncodesFrameParamsUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put( + "connectors", + buildJsonArray { + add( + buildJsonObject { + put("id", "c1") + put("status", "connected") + }, + ) + }, + ) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connectors.list") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesRpcCaller.call( + RpcMethods.CONNECTORS_LIST, + ConnectorsListParams( + owner = ConnectorOwner.session("s1"), + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("connectors.list", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "s1") + }, + ) + } + assertEquals(expectedParams, params) + assertEquals(serverResultJson, result) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index dae2bca2..aeee33fa 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -316,17 +316,6 @@ class RawRpcParamsRatchetTest { */ val BASELINE: List = listOf( - "data/ws/AccountConnectorRepository.kt::CONNECTORS_ACCOUNTS", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_ACCOUNTS_REMOVE", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_CATALOG", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_CONNECT", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_LIST", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_OPERATION_STATUS", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_POLICY_GET", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_POLICY_SET", - "data/ws/AccountConnectorRepository.kt::CONNECTORS_TOOLS", - "data/ws/ConnectorRepository.kt::CONNECTORS_CONNECT", - "data/ws/ConnectorRepository.kt::CONNECTORS_LIST", "data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES", "data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS", "data/ws/PluginManageRepository.kt::PLUGINS_MANAGE", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectorsTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectorsTypesTest.kt new file mode 100644 index 00000000..9203210c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectorsTypesTest.kt @@ -0,0 +1,539 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectorsTypesTest { + @Test + fun connectorOwnerAccountEncodesToTypeAccountWithoutSessionId() { + val owner = ConnectorOwner.account() + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorOwner.serializer(), owner) + + val expected = + buildJsonObject { + put("type", "account") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("type"), (encoded as JsonObject).keys) + } + + @Test + fun connectorOwnerSessionEncodesToTypeSessionAndSessionId() { + val owner = ConnectorOwner.session("s1") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorOwner.serializer(), owner) + + val expected = + buildJsonObject { + put("type", "session") + put("session_id", "s1") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("type", "session_id"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsListParamsEncodesOwnerAndProfileWhenProfileProvided() { + val params = ConnectorsListParams(owner = ConnectorOwner.account(), profile = "work") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsListParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsListParamsOmitsProfileWhenNull() { + val params = ConnectorsListParams(owner = ConnectorOwner.session("s1"), profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsListParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "s1") + }, + ) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsConnectParamsEncodesAllFieldsWhenPresent() { + val params = + ConnectorsConnectParams( + owner = ConnectorOwner.account(), + connectors = listOf("slack", "github"), + reconnect = true, + profile = "personal", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put( + "connectors", + buildJsonArray { + add(JsonPrimitive("slack")) + add(JsonPrimitive("github")) + }, + ) + put("reconnect", true) + put("profile", "personal") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "connectors", "reconnect", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsConnectParamsEncodesExplicitFalseForReconnect() { + val params = + ConnectorsConnectParams( + owner = ConnectorOwner.session("s2"), + connectors = listOf("jira"), + reconnect = false, + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "s2") + }, + ) + put( + "connectors", + buildJsonArray { + add(JsonPrimitive("jira")) + }, + ) + put("reconnect", false) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "connectors", "reconnect"), (encoded as JsonObject).keys) + assertFalse((encoded as JsonObject).containsKey("profile")) + } + + @Test + fun connectorsConnectParamsOmitsNullReconnectAndProfile() { + val params = + ConnectorsConnectParams( + owner = ConnectorOwner.account(), + connectors = emptyList(), + reconnect = null, + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("connectors", buildJsonArray {}) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "connectors"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsOperationStatusParamsEncodesAllFieldsWhenPresent() { + val params = + ConnectorsOperationStatusParams( + owner = ConnectorOwner.session("sess-1"), + opId = "op-99", + profile = "work", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-1") + }, + ) + put("op_id", "op-99") + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsOperationStatusParamsOmitsNullProfile() { + val params = + ConnectorsOperationStatusParams( + owner = ConnectorOwner.account(), + opId = "op-100", + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("op_id", "op-100") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsCatalogParamsEncodesProfileWhenPresent() { + val params = ConnectorsCatalogParams(profile = "default") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsCatalogParams.serializer(), params) + + val expected = + buildJsonObject { + put("profile", "default") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsCatalogParamsOmitsNullProfileResultingInEmptyJsonObject() { + val params = ConnectorsCatalogParams(profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsCatalogParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun connectorsAccountsParamsEncodesAllFieldsWhenPresent() { + val params = ConnectorsAccountsParams(connector = "google", profile = "work") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params) + + val expected = + buildJsonObject { + put("connector", "google") + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("connector", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsAccountsParamsOmitsNullFieldsResultingInEmptyJsonObject() { + val params = ConnectorsAccountsParams(connector = null, profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun connectorsAccountsParamsEncodesConnectorOnlyWhenProfileNull() { + val params = ConnectorsAccountsParams(connector = "github", profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params) + + val expected = + buildJsonObject { + put("connector", "github") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("connector"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsAccountsRemoveParamsEncodesConnectionIdAndProfile() { + val params = ConnectorsAccountsRemoveParams(connectionId = "conn-123", profile = "work") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsRemoveParams.serializer(), params) + + val expected = + buildJsonObject { + put("connection_id", "conn-123") + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("connection_id", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsAccountsRemoveParamsOmitsNullProfile() { + val params = ConnectorsAccountsRemoveParams(connectionId = "conn-456", profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsRemoveParams.serializer(), params) + + val expected = + buildJsonObject { + put("connection_id", "conn-456") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("connection_id"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsPolicyGetParamsEncodesProfileWhenPresent() { + val params = ConnectorsPolicyGetParams(profile = "custom-profile") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicyGetParams.serializer(), params) + + val expected = + buildJsonObject { + put("profile", "custom-profile") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsPolicyGetParamsOmitsNullProfileResultingInEmptyJsonObject() { + val params = ConnectorsPolicyGetParams(profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicyGetParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun connectorsPolicySetParamsEncodesNestedChangeWithArraysUntouched() { + val nestedChange = + buildJsonObject { + put("action", "allow") + put( + "tools", + buildJsonArray { + add(JsonPrimitive("gmail.send")) + add(JsonPrimitive("gmail.list")) + }, + ) + put( + "scope", + buildJsonObject { + put("level", "read-write") + put( + "allowed_users", + buildJsonArray { + add(JsonPrimitive("alice")) + add(JsonPrimitive("bob")) + }, + ) + }, + ) + } + val params = + ConnectorsPolicySetParams( + change = nestedChange, + expectedRevision = "4", + profile = "work", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicySetParams.serializer(), params) + + val expected = + buildJsonObject { + put("change", nestedChange) + put("expected_revision", "4") + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("change", "expected_revision", "profile"), (encoded as JsonObject).keys) + assertEquals(nestedChange, (encoded as JsonObject)["change"]) + } + + @Test + fun connectorsPolicySetParamsOmitsNullProfile() { + val change = buildJsonObject { put("enabled", true) } + val params = + ConnectorsPolicySetParams( + change = change, + expectedRevision = "1", + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicySetParams.serializer(), params) + + val expected = + buildJsonObject { + put("change", change) + put("expected_revision", "1") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("change", "expected_revision"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsToolsParamsEncodesAllFieldsWhenPresent() { + val params = + ConnectorsToolsParams( + slug = "gmail", + refresh = true, + profile = "work", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params) + + val expected = + buildJsonObject { + put("slug", "gmail") + put("refresh", true) + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("slug", "refresh", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsToolsParamsEncodesExplicitFalseForRefresh() { + val params = + ConnectorsToolsParams( + slug = "slack", + refresh = false, + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params) + + val expected = + buildJsonObject { + put("slug", "slack") + put("refresh", false) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("slug", "refresh"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsToolsParamsOmitsNullRefreshAndProfile() { + val params = + ConnectorsToolsParams( + slug = "github", + refresh = null, + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params) + + val expected = + buildJsonObject { + put("slug", "github") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("slug"), (encoded as JsonObject).keys) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("connectors.list", RpcMethods.CONNECTORS_LIST.name) + assertEquals("connectors.connect", RpcMethods.CONNECTORS_CONNECT.name) + assertEquals("connectors.operation.status", RpcMethods.CONNECTORS_OPERATION_STATUS.name) + assertEquals("connectors.catalog", RpcMethods.CONNECTORS_CATALOG.name) + assertEquals("connectors.accounts", RpcMethods.CONNECTORS_ACCOUNTS.name) + assertEquals("connectors.accounts.remove", RpcMethods.CONNECTORS_ACCOUNTS_REMOVE.name) + assertEquals("connectors.policy.get", RpcMethods.CONNECTORS_POLICY_GET.name) + assertEquals("connectors.policy.set", RpcMethods.CONNECTORS_POLICY_SET.name) + assertEquals("connectors.tools", RpcMethods.CONNECTORS_TOOLS.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_LIST)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_CONNECT)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_OPERATION_STATUS)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_CATALOG)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_ACCOUNTS)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_ACCOUNTS_REMOVE)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_POLICY_GET)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_POLICY_SET)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_TOOLS)) + } + + @Test + fun jsonElementPassthroughMethodsDecodeResultUntouched() { + val arbitraryPayload = + buildJsonObject { + put("status", "ok") + put( + "items", + buildJsonArray { + add(JsonPrimitive("elem1")) + add(JsonPrimitive("elem2")) + }, + ) + put("nested", buildJsonObject { put("revision", 10) }) + } + + val methods = + listOf( + RpcMethods.CONNECTORS_LIST, + RpcMethods.CONNECTORS_CONNECT, + RpcMethods.CONNECTORS_OPERATION_STATUS, + RpcMethods.CONNECTORS_CATALOG, + RpcMethods.CONNECTORS_ACCOUNTS, + RpcMethods.CONNECTORS_ACCOUNTS_REMOVE, + RpcMethods.CONNECTORS_POLICY_GET, + RpcMethods.CONNECTORS_POLICY_SET, + RpcMethods.CONNECTORS_TOOLS, + ) + + for (method in methods) { + val decoded: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + method.result, + arbitraryPayload, + ) + assertEquals("Method " + method.name + " must pass raw result untouched", arbitraryPayload, decoded) + } + } +} From c8edf3665ff1a2346625a71077a63cda0e170f2c Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:28:40 +0300 Subject: [PATCH 073/107] refactor(#1375): type connector operation responses (#1388) --- .../data/ws/AccountConnectorRepository.kt | 53 +- .../data/ws/contract/ConnectionOperation.kt | 26 + .../data/ws/contract/RpcMethod.kt | 18 + .../chat/ChatConnectionOperationDelegate.kt | 88 +-- .../hermescontrol/ui/chat/ChatViewModel.kt | 12 +- .../connectors/AccountConnectorsViewModel.kt | 9 +- .../data/ws/AccountConnectorRepositoryTest.kt | 40 +- .../HermesWsClientConnectionOperationTest.kt | 562 ++++++++++++++++++ .../data/ws/RawRpcParamsRatchetTest.kt | 3 - .../contract/ConnectionOperationTypesTest.kt | 257 ++++++++ .../ChatConnectionOperationDelegateTest.kt | 208 +++++-- .../AccountConnectorsViewModelTest.kt | 17 +- 12 files changed, 1168 insertions(+), 125 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperation.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectionOperationTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperationTypesTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt index 4c6a4042..67fd9d97 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt @@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.model.ConnectorCatalogEntry import com.m57.hermescontrol.data.model.ConnectorError import com.m57.hermescontrol.data.model.ConnectorPolicy import com.m57.hermescontrol.data.model.ConnectorTool +import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams import com.m57.hermescontrol.data.ws.contract.ConnectorOwner import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsParams import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsRemoveParams @@ -61,10 +62,9 @@ interface AccountConnectorRepository { expectedRevision: String, ): AccountConnectorResult - suspend fun operationRequest( - method: String, - params: Map, - ): Any? + suspend fun operationRespond(params: ConnectionRespondParams): Any? + + suspend fun operationWake(params: ConnectorsOperationStatusParams): Any? suspend fun operationStatus( opId: String, @@ -74,26 +74,8 @@ interface AccountConnectorRepository { } class HermesAccountConnectorRepository( - private val rpc: suspend (String, Map) -> Any? = { method, params -> - val deferred = HermesWsClient.request(method, params) - try { - deferred.await() - } catch ( - e: CancellationException, - ) { - deferred.cancel(e) - throw e - } finally { - if (!deferred.isCompleted) deferred.cancel() - } - }, private val caller: TypedRpcCaller = HermesRpcCaller, ) : AccountConnectorRepository { - private suspend fun request( - method: String, - params: Map = emptyMap(), - ) = rpc(method, params) - private suspend fun guarded(block: suspend () -> T): AccountConnectorResult = try { AccountConnectorResult.Success(block()) @@ -111,12 +93,6 @@ class HermesAccountConnectorRepository( AccountConnectorResult.Failure(ConnectorError.NetworkError("Failed to communicate with gateway.")) } - private fun accountParams(params: Map = emptyMap()): Map = - buildMap { - putAll(params) - AuthManager.activeProfileId.value?.let { put("profile", it) } - } - private fun obj(value: Any?): JsonObject = ConnectorParser.toJsonElement(value).jsonObject private fun JsonObject.str(k: String) = this[k]?.jsonPrimitive?.contentOrNull.orEmpty() @@ -173,10 +149,23 @@ class HermesAccountConnectorRepository( ) } - override suspend fun operationRequest( - method: String, - params: Map, - ): Any? = request(method, accountParams(params + ("owner" to mapOf("type" to "account")))) + override suspend fun operationRespond(params: ConnectionRespondParams): Any? = + caller.call( + RpcMethods.CONNECTION_RESPOND, + params.copy( + owner = ConnectorOwner.account(), + profile = AuthManager.activeProfileId.value, + ), + ) + + override suspend fun operationWake(params: ConnectorsOperationStatusParams): Any? = + caller.call( + RpcMethods.CONNECTORS_OPERATION_WAKE, + params.copy( + owner = ConnectorOwner.account(), + profile = AuthManager.activeProfileId.value, + ), + ) override suspend fun operationStatus(opId: String) = guarded { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperation.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperation.kt new file mode 100644 index 00000000..496d9451 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperation.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ConnectionAnswerTarget( + @SerialName("name") val name: String, + @SerialName("status") val status: String, + @SerialName("detail") val detail: String? = null, + @SerialName("env") val env: Map? = null, +) + +@Serializable +data class ConnectionAnswer( + @SerialName("targets") val targets: List? = null, + @SerialName("settled_by") val settledBy: String? = null, +) + +@Serializable +data class ConnectionRespondParams( + @SerialName("owner") val owner: ConnectorOwner, + @SerialName("op_id") val opId: String, + @SerialName("result") val result: ConnectionAnswer, + @SerialName("profile") val profile: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index a123c18d..8b0266f3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -315,6 +315,22 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTION_RESPOND: RpcMethod = + RpcMethod( + WsMethods.CONNECTION_RESPOND, + ConnectionRespondParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_OPERATION_WAKE: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_OPERATION_WAKE, + ConnectorsOperationStatusParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -356,5 +372,7 @@ object RpcMethods { CONNECTORS_POLICY_GET, CONNECTORS_POLICY_SET, CONNECTORS_TOOLS, + CONNECTION_RESPOND, + CONNECTORS_OPERATION_WAKE, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt index 14ab2ba6..b541d561 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt @@ -3,14 +3,27 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot import com.m57.hermescontrol.data.model.ConnectorError import com.m57.hermescontrol.data.ws.ConnectorParser -import com.m57.hermescontrol.data.ws.ConnectorRepository import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswerTarget +import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +sealed interface ConnectionOperationRequest { + data class Respond( + val params: ConnectionRespondParams, + ) : ConnectionOperationRequest + + data class Wake( + val params: ConnectorsOperationStatusParams, + ) : ConnectionOperationRequest +} + sealed class ConnectionPendingAction { abstract val opId: String abstract val observedSeq: Long @@ -45,10 +58,7 @@ data class ConnectionOperationUiState( ) fun interface ConnectionOperationRequester { - suspend fun request( - method: String, - params: Map, - ): Any? + suspend fun request(action: ConnectionOperationRequest): Any? } internal class ConnectionResumeCheckpoint( @@ -163,19 +173,20 @@ class ChatConnectionOperationDelegate( observedSeq = current.seq, ), ) ?: return - val answer = - buildMap { - put("name", target) - put("status", if (approved) "approved" else "skipped") - if (safeEnv.isNotEmpty()) put("env", safeEnv) - } + val answerTarget = + ConnectionAnswerTarget( + name = target, + status = if (approved) "approved" else "skipped", + env = safeEnv.takeIf { it.isNotEmpty() }, + ) dispatch( - method = WsMethods.CONNECTION_RESPOND, - params = - mapOf( - "owner" to ownerParams(), - "op_id" to snapshot.opId, - "result" to mapOf("targets" to listOf(answer)), + action = + ConnectionOperationRequest.Respond( + ConnectionRespondParams( + owner = owner(), + opId = snapshot.opId, + result = ConnectionAnswer(targets = listOf(answerTarget)), + ), ), ) } @@ -183,12 +194,13 @@ class ChatConnectionOperationDelegate( suspend fun continueOperation() { val snapshot = begin(ConnectionPendingAction.Continue(currentOp(), currentSeq())) ?: return dispatch( - method = WsMethods.CONNECTION_RESPOND, - params = - mapOf( - "owner" to ownerParams(), - "op_id" to snapshot.opId, - "result" to mapOf("settled_by" to "continue"), + action = + ConnectionOperationRequest.Respond( + ConnectionRespondParams( + owner = owner(), + opId = snapshot.opId, + result = ConnectionAnswer(settledBy = "continue"), + ), ), ) } @@ -198,36 +210,40 @@ class ChatConnectionOperationDelegate( if (current.opId != expectedOpId) return val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return dispatch( - method = WsMethods.CONNECTORS_OPERATION_WAKE, - params = mapOf("owner" to ownerParams(), "op_id" to snapshot.opId), + action = + ConnectionOperationRequest.Wake( + ConnectorsOperationStatusParams( + owner = owner(), + opId = snapshot.opId, + ), + ), clearOnSuccess = true, unknownOperationSettles = true, ) } private suspend fun dispatch( - method: String, - params: Map, + action: ConnectionOperationRequest, clearOnSuccess: Boolean = false, unknownOperationSettles: Boolean = false, ) { - val action = _state.value.pendingAction + val pending = _state.value.pendingAction val actionGeneration = generation try { - requester.request(method, params) + requester.request(action) // Success only acknowledges receipt. Keep the exactly-once lock until // a newer authoritative snapshot advances the operation sequence. - if (clearOnSuccess && generation == actionGeneration && _state.value.pendingAction == action) { + if (clearOnSuccess && generation == actionGeneration && _state.value.pendingAction == pending) { _state.value = _state.value.copy(pendingAction = null) } } catch (error: CancellationException) { throw error } catch (error: Exception) { if (unknownOperationSettles && error is HermesWsClient.HermesRpcException && error.code == 4004) { - if (generation == actionGeneration && _state.value.pendingAction == action) { - action?.opId?.let(::markSettled) + if (generation == actionGeneration && _state.value.pendingAction == pending) { + pending?.opId?.let(::markSettled) } - } else if (generation == actionGeneration && _state.value.pendingAction == action) { + } else if (generation == actionGeneration && _state.value.pendingAction == pending) { _state.value = _state.value.copy( pendingAction = null, @@ -269,8 +285,8 @@ class ChatConnectionOperationDelegate( snapshot.sessionId == sessionId } - private fun ownerParams(): Map = - if (accountOwned) mapOf("type" to "account") else ConnectorRepository.sessionOwner(checkNotNull(sessionId)) + private fun owner(): ConnectorOwner = + if (accountOwned) ConnectorOwner.account() else ConnectorOwner.session(checkNotNull(sessionId)) private fun currentOp(): String = _state.value.operation 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 87ae2a2b..f1dffe6a 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 @@ -461,8 +461,16 @@ class ChatViewModel( constructor(application: Application) : this(application, startCleanup = true) private val connectionOperationDelegate = - ChatConnectionOperationDelegate { method, params -> - HermesWsClient.request(method, params).await() + ChatConnectionOperationDelegate { action -> + when (action) { + is ConnectionOperationRequest.Respond -> { + HermesWsClient.call(RpcMethods.CONNECTION_RESPOND, action.params) + } + + is ConnectionOperationRequest.Wake -> { + HermesWsClient.call(RpcMethods.CONNECTORS_OPERATION_WAKE, action.params) + } + } } // ── Internal state ─────────────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt index aacfe702..aab702ee 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt @@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.ws.AccountConnectorRepository import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.ui.chat.ChatConnectionOperationDelegate +import com.m57.hermescontrol.ui.chat.ConnectionOperationRequest import com.m57.hermescontrol.ui.chat.ConnectionOperationRequester import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job @@ -51,7 +52,13 @@ class AccountConnectorsViewModel( private val scopedJobs = mutableListOf() private val operation = ChatConnectionOperationDelegate( - requester = ConnectionOperationRequester(repository::operationRequest), + requester = + ConnectionOperationRequester { action -> + when (action) { + is ConnectionOperationRequest.Respond -> repository.operationRespond(action.params) + is ConnectionOperationRequest.Wake -> repository.operationWake(action.params) + } + }, accountOwned = true, ) val connectionState = operation.state diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt index 8db2b0f4..79654fc6 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt @@ -3,6 +3,10 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.AccountConnectorResult import com.m57.hermescontrol.data.model.ConnectorTool +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer +import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams import io.mockk.every import io.mockk.mockkObject import io.mockk.unmockkAll @@ -62,14 +66,19 @@ class AccountConnectorRepositoryTest { operationFixture() } repo.operationStatus("op-a") - repo.operationRequest( - WsMethods.CONNECTION_RESPOND, - mapOf( - "op_id" to "op-a", - "result" to mapOf("settled_by" to "continue"), + repo.operationRespond( + ConnectionRespondParams( + owner = ConnectorOwner.account(), + opId = "op-a", + result = ConnectionAnswer(settledBy = "continue"), + ), + ) + repo.operationWake( + ConnectorsOperationStatusParams( + owner = ConnectorOwner.account(), + opId = "op-a", ), ) - repo.operationRequest(WsMethods.CONNECTORS_OPERATION_WAKE, mapOf("op_id" to "op-a")) assertEquals( listOf( WsMethods.CONNECTORS_OPERATION_STATUS, @@ -86,6 +95,23 @@ class AccountConnectorRepositoryTest { assertEquals("op-a", params["op_id"]) assertFalse("session_id" in params) } + assertEquals( + mapOf( + "owner" to mapOf("type" to "account"), + "op_id" to "op-a", + "result" to mapOf("settled_by" to "continue"), + "profile" to "work", + ), + calls[1].second, + ) + assertEquals( + mapOf( + "owner" to mapOf("type" to "account"), + "op_id" to "op-a", + "profile" to "work", + ), + calls[2].second, + ) } @Test fun `catalog accounts tools and removal use native account endpoints`() = @@ -213,7 +239,7 @@ class AccountConnectorRepositoryTest { } private fun accountRepo(handler: suspend (String, Map) -> Any?) = - HermesAccountConnectorRepository(rpc = handler, caller = fakeCaller(handler)) + HermesAccountConnectorRepository(caller = fakeCaller(handler)) private fun operationFixture() = mapOf( diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectionOperationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectionOperationTest.kt new file mode 100644 index 00000000..a3817d97 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectionOperationTest.kt @@ -0,0 +1,562 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswerTarget +import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientConnectionOperationTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeoutOrNull(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } + } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun typedCallConnectionRespondWithApprovedEnvAndAccountOwnerEncodesFrameAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("status", "ok") + put("settled", true) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connection.respond") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.CONNECTION_RESPOND, + ConnectionRespondParams( + owner = ConnectorOwner.account(), + opId = "op-approved-123", + result = + ConnectionAnswer( + targets = + listOf( + ConnectionAnswerTarget( + name = "github", + status = "approved", + detail = "OAuth granted", + env = mapOf("TOKEN" to "dummy_secret_token_abc"), + ), + ), + settledBy = null, + ), + profile = "prod", + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("op_id", "op-approved-123") + put( + "result", + buildJsonObject { + put( + "targets", + buildJsonArray { + add( + buildJsonObject { + put("name", "github") + put("status", "approved") + put("detail", "OAuth granted") + put( + "env", + buildJsonObject { + put("TOKEN", "dummy_secret_token_abc") + }, + ) + }, + ) + }, + ) + }, + ) + put("profile", "prod") + } + assertEquals(expectedParams, params) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallConnectionRespondWithSkippedOmittedEnvAndSessionOwnerEncodesFrameAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("status", "ok") + put("settled", false) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connection.respond") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.CONNECTION_RESPOND, + ConnectionRespondParams( + owner = ConnectorOwner.session("sess-skip-456"), + opId = "op-skip-456", + result = + ConnectionAnswer( + targets = + listOf( + ConnectionAnswerTarget( + name = "slack", + status = "skipped", + detail = null, + env = null, + ), + ), + settledBy = null, + ), + profile = null, + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-skip-456") + }, + ) + put("op_id", "op-skip-456") + put( + "result", + buildJsonObject { + put( + "targets", + buildJsonArray { + add( + buildJsonObject { + put("name", "slack") + put("status", "skipped") + }, + ) + }, + ) + }, + ) + } + assertEquals(expectedParams, params) + assertFalse(params?.containsKey("profile") ?: true) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallConnectionRespondWithContinueAndOmittedTargetsEncodesFrameAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("status", "ok") + put("settled", true) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connection.respond") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.CONNECTION_RESPOND, + ConnectionRespondParams( + owner = ConnectorOwner.session("sess-continue-789"), + opId = "op-continue-789", + result = + ConnectionAnswer( + targets = null, + settledBy = "continue", + ), + profile = null, + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-continue-789") + }, + ) + put("op_id", "op-continue-789") + put( + "result", + buildJsonObject { + put("settled_by", "continue") + }, + ) + } + assertEquals(expectedParams, params) + assertFalse(params?.containsKey("profile") ?: true) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallConnectorsOperationWakeEncodesOwnerSessionOpIdAndProfileAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("op_id", "op-wake-777") + put("status", "running") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connectors.operation.wake") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.CONNECTORS_OPERATION_WAKE, + ConnectorsOperationStatusParams( + owner = ConnectorOwner.session("sess-wake-99"), + opId = "op-wake-777", + profile = "custom-profile", + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("connectors.operation.wake", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-wake-99") + }, + ) + put("op_id", "op-wake-777") + put("profile", "custom-profile") + } + assertEquals(expectedParams, params) + assertEquals(serverResultJson, result) + } + + @Test + fun hermesRpcCallerCallDelegatesForWakeWithAccountOwnerAndNullProfile() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("op_id", "op-wake-888") + put("status", "done") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connectors.operation.wake") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesRpcCaller.call( + RpcMethods.CONNECTORS_OPERATION_WAKE, + ConnectorsOperationStatusParams( + owner = ConnectorOwner.account(), + opId = "op-wake-888", + profile = null, + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("connectors.operation.wake", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("op_id", "op-wake-888") + } + assertEquals(expectedParams, params) + assertFalse(params?.containsKey("profile") ?: true) + assertEquals(serverResultJson, result) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index aeee33fa..0eef3578 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -332,9 +332,6 @@ class RawRpcParamsRatchetTest { "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", "ui/bots/group/GroupChatViewModel.kt::PROFILES_LIST", - "ui/chat/ChatConnectionOperationDelegate.kt::CONNECTION_RESPOND", - "ui/chat/ChatConnectionOperationDelegate.kt::CONNECTION_RESPOND", - "ui/chat/ChatConnectionOperationDelegate.kt::CONNECTORS_OPERATION_WAKE", "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperationTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperationTypesTest.kt new file mode 100644 index 00000000..49ea10cb --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperationTypesTest.kt @@ -0,0 +1,257 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectionOperationTypesTest { + @Test + fun connectionRespondParamsWithApprovedEnvAndAccountOwnerEncodesExactWireJson() { + val params = + ConnectionRespondParams( + owner = ConnectorOwner.account(), + opId = "op-approved-1", + result = + ConnectionAnswer( + targets = + listOf( + ConnectionAnswerTarget( + name = "linear", + status = "approved", + detail = "User accepted linear integration", + env = mapOf("API_KEY" to "dummy_secret_key_123"), + ), + ), + settledBy = null, + ), + profile = "work", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("op_id", "op-approved-1") + put( + "result", + buildJsonObject { + put( + "targets", + buildJsonArray { + add( + buildJsonObject { + put("name", "linear") + put("status", "approved") + put("detail", "User accepted linear integration") + put( + "env", + buildJsonObject { + put("API_KEY", "dummy_secret_key_123") + }, + ) + }, + ) + }, + ) + }, + ) + put("profile", "work") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id", "result", "profile"), (encoded as JsonObject).keys) + val resultObj = (encoded as JsonObject)["result"] as JsonObject + assertEquals(setOf("targets"), resultObj.keys) + assertFalse(resultObj.containsKey("settled_by")) + } + + @Test + fun connectionRespondParamsWithSkippedOmittedEnvAndSessionOwnerEncodesExactWireJson() { + val params = + ConnectionRespondParams( + owner = ConnectorOwner.session("sess-xyz"), + opId = "op-skip-2", + result = + ConnectionAnswer( + targets = + listOf( + ConnectionAnswerTarget( + name = "github", + status = "skipped", + detail = null, + env = null, + ), + ), + settledBy = null, + ), + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-xyz") + }, + ) + put("op_id", "op-skip-2") + put( + "result", + buildJsonObject { + put( + "targets", + buildJsonArray { + add( + buildJsonObject { + put("name", "github") + put("status", "skipped") + }, + ) + }, + ) + }, + ) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id", "result"), (encoded as JsonObject).keys) + assertFalse((encoded as JsonObject).containsKey("profile")) + + val resultObj = (encoded as JsonObject)["result"] as JsonObject + assertEquals(setOf("targets"), resultObj.keys) + assertFalse(resultObj.containsKey("settled_by")) + + val targetObj = (resultObj["targets"] as kotlinx.serialization.json.JsonArray)[0] as JsonObject + assertEquals(setOf("name", "status"), targetObj.keys) + assertFalse(targetObj.containsKey("env")) + assertFalse(targetObj.containsKey("detail")) + } + + @Test + fun connectionRespondParamsWithContinueAndOmittedTargetsEncodesExactWireJson() { + val params = + ConnectionRespondParams( + owner = ConnectorOwner.session("sess-cont-3"), + opId = "op-cont-3", + result = + ConnectionAnswer( + targets = null, + settledBy = "continue", + ), + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-cont-3") + }, + ) + put("op_id", "op-cont-3") + put( + "result", + buildJsonObject { + put("settled_by", "continue") + }, + ) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id", "result"), (encoded as JsonObject).keys) + assertFalse((encoded as JsonObject).containsKey("profile")) + + val resultObj = (encoded as JsonObject)["result"] as JsonObject + assertEquals(setOf("settled_by"), resultObj.keys) + assertFalse(resultObj.containsKey("targets")) + } + + @Test + fun connectorsOperationWakeUsesConnectorsOperationStatusParamsAndEncodesAllFieldsWhenProfilePresent() { + val params = + ConnectorsOperationStatusParams( + owner = ConnectorOwner.session("sess-wake-1"), + opId = "op-wake-1", + profile = "staging", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-wake-1") + }, + ) + put("op_id", "op-wake-1") + put("profile", "staging") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsOperationWakeUsesConnectorsOperationStatusParamsAndOmitsProfileWhenNull() { + val params = + ConnectorsOperationStatusParams( + owner = ConnectorOwner.account(), + opId = "op-wake-2", + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("op_id", "op-wake-2") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id"), (encoded as JsonObject).keys) + assertFalse((encoded as JsonObject).containsKey("profile")) + } + + @Test + fun connectionRespondAndWakeAreRegisteredInGatewayContractRpcMethodsAll() { + val allMethods = RpcMethods.all + val respondMethod = allMethods.firstOrNull { it.name == "connection.respond" } + val wakeMethod = allMethods.firstOrNull { it.name == "connectors.operation.wake" } + + org.junit.Assert.assertNotNull("connection.respond must be registered in RpcMethods.all", respondMethod) + org.junit.Assert.assertNotNull("connectors.operation.wake must be registered in RpcMethods.all", wakeMethod) + + assertEquals(RpcMethods.CONNECTION_RESPOND, respondMethod) + assertEquals(RpcMethods.CONNECTORS_OPERATION_WAKE, wakeMethod) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt index 2a4b1315..3d31b0ae 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt @@ -6,8 +6,13 @@ import com.m57.hermescontrol.data.model.ConnectionOperationTarget import com.m57.hermescontrol.data.model.ConnectionTargetAction import com.m57.hermescontrol.data.model.ConnectionTargetKind import com.m57.hermescontrol.data.model.ConnectionTargetState +import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswerTarget +import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -15,6 +20,9 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -109,19 +117,65 @@ class ChatConnectionOperationDelegateTest { ) assertEquals(1, requester.calls.size) - val call = requester.calls.single() - assertEquals(WsMethods.CONNECTION_RESPOND, call.first) - assertEquals(setOf("owner", "op_id", "result"), call.second.keys) - assertEquals(mapOf("type" to "session", "session_id" to "session-a"), call.second["owner"]) - assertEquals("op-a", call.second["op_id"]) - @Suppress("UNCHECKED_CAST") - val result = call.second["result"] as Map + val action = requester.calls.single() + assertTrue(action is ConnectionOperationRequest.Respond) + val respond = action as ConnectionOperationRequest.Respond + + assertEquals( + ConnectionRespondParams( + owner = ConnectorOwner.session("session-a"), + opId = "op-a", + result = + ConnectionAnswer( + targets = + listOf( + ConnectionAnswerTarget( + name = "github", + status = "approved", + env = mapOf("TOKEN" to canary), + ), + ), + ), + ), + respond.params, + ) + + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), respond.params) + val expectedWire = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "session-a") + }, + ) + put("op_id", "op-a") + put( + "result", + buildJsonObject { + put( + "targets", + buildJsonArray { + add( + buildJsonObject { + put("name", "github") + put("status", "approved") + put( + "env", + buildJsonObject { + put("TOKEN", canary) + }, + ) + }, + ) + }, + ) + }, + ) + } + assertEquals(expectedWire, encoded) - @Suppress("UNCHECKED_CAST") - val answer = (result["targets"] as List>).single() - assertEquals("github", answer["name"]) - assertEquals("approved", answer["status"]) - assertEquals(mapOf("TOKEN" to canary), answer["env"]) assertFalse( delegate.state.value .toString() @@ -166,12 +220,57 @@ class ChatConnectionOperationDelegateTest { skipDelegate.acceptRequest(snapshot()) skipDelegate.respond("github", mapOf("TOKEN" to "must-drop"), approved = false) - @Suppress("UNCHECKED_CAST") - val skipResult = skipRequester.calls.single().second["result"] as Map + val skipAction = skipRequester.calls.single() + assertTrue(skipAction is ConnectionOperationRequest.Respond) + val skipRespond = skipAction as ConnectionOperationRequest.Respond + assertEquals( + ConnectionRespondParams( + owner = ConnectorOwner.session("session-a"), + opId = "op-a", + result = + ConnectionAnswer( + targets = + listOf( + ConnectionAnswerTarget( + name = "github", + status = "skipped", + ), + ), + ), + ), + skipRespond.params, + ) - @Suppress("UNCHECKED_CAST") - val skipAnswer = (skipResult["targets"] as List>).single() - assertEquals(mapOf("name" to "github", "status" to "skipped"), skipAnswer) + val encodedSkip = + OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), skipRespond.params) + val expectedSkipWire = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "session-a") + }, + ) + put("op_id", "op-a") + put( + "result", + buildJsonObject { + put( + "targets", + buildJsonArray { + add( + buildJsonObject { + put("name", "github") + put("status", "skipped") + }, + ) + }, + ) + }, + ) + } + assertEquals(expectedSkipWire, encodedSkip) val continueRequester = RecordingRequester() val continueDelegate = delegate(continueRequester) @@ -179,14 +278,38 @@ class ChatConnectionOperationDelegateTest { continueDelegate.acceptRequest(snapshot()) continueDelegate.continueOperation() + val continueAction = continueRequester.calls.single() + assertTrue(continueAction is ConnectionOperationRequest.Respond) + val continueRespond = continueAction as ConnectionOperationRequest.Respond assertEquals( - mapOf( - "owner" to mapOf("type" to "session", "session_id" to "session-a"), - "op_id" to "op-a", - "result" to mapOf("settled_by" to "continue"), + ConnectionRespondParams( + owner = ConnectorOwner.session("session-a"), + opId = "op-a", + result = ConnectionAnswer(settledBy = "continue"), ), - continueRequester.calls.single().second, + continueRespond.params, ) + + val encodedContinue = + OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), continueRespond.params) + val expectedContinueWire = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "session-a") + }, + ) + put("op_id", "op-a") + put( + "result", + buildJsonObject { + put("settled_by", "continue") + }, + ) + } + assertEquals(expectedContinueWire, encodedContinue) } @Test @@ -199,15 +322,31 @@ class ChatConnectionOperationDelegateTest { delegate.wake("op-a") - val call = requester.calls.single() - assertEquals(WsMethods.CONNECTORS_OPERATION_WAKE, call.first) + val action = requester.calls.single() + assertTrue(action is ConnectionOperationRequest.Wake) + val wake = action as ConnectionOperationRequest.Wake assertEquals( - mapOf( - "owner" to mapOf("type" to "session", "session_id" to "session-a"), - "op_id" to "op-a", + ConnectorsOperationStatusParams( + owner = ConnectorOwner.session("session-a"), + opId = "op-a", ), - call.second, + wake.params, ) + + val encoded = + OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), wake.params) + val expectedWire = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "session-a") + }, + ) + put("op_id", "op-a") + } + assertEquals(expectedWire, encoded) } @Test @@ -265,7 +404,7 @@ class ChatConnectionOperationDelegateTest { runTest { val gate = CompletableDeferred() val requester = - ConnectionOperationRequester { _, _ -> + ConnectionOperationRequester { gate.await() throw IllegalStateException("old failure") } @@ -355,13 +494,10 @@ class ChatConnectionOperationDelegateTest { private class RecordingRequester( private val failure: Throwable? = null, ) : ConnectionOperationRequester { - val calls = mutableListOf>>() + val calls = mutableListOf() - override suspend fun request( - method: String, - params: Map, - ): Any? { - calls += method to params + override suspend fun request(action: ConnectionOperationRequest): Any? { + calls += action failure?.let { throw it } return Unit } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt index e6ff4c9e..530bb7d1 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt @@ -10,7 +10,9 @@ import com.m57.hermescontrol.data.model.ConnectorPolicy import com.m57.hermescontrol.data.ws.AccountConnectorRepository import com.m57.hermescontrol.data.ws.ConnectionOperationParser import com.m57.hermescontrol.data.ws.WsEvent -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer +import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -143,7 +145,7 @@ class AccountConnectorsViewModelTest { runTest(dispatcher) { advanceUntilIdle() coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot()) - coEvery { repo.operationRequest(any(), any()) } returns mapOf("status" to "ok") + coEvery { repo.operationRespond(any()) } returns mapOf("status" to "ok") coEvery { repo.operationStatus("op-a") } returns AccountConnectorResult.Success(snapshot().copy(seq = 2, settled = true)) vm.connect("drive") @@ -151,12 +153,11 @@ class AccountConnectorsViewModelTest { vm.continueOperation() advanceUntilIdle() coVerify { - repo.operationRequest( - WsMethods.CONNECTION_RESPOND, - mapOf( - "owner" to mapOf("type" to "account"), - "op_id" to "op-a", - "result" to mapOf("settled_by" to "continue"), + repo.operationRespond( + ConnectionRespondParams( + owner = ConnectorOwner.account(), + opId = "op-a", + result = ConnectionAnswer(settledBy = "continue"), ), ) } From 27ce528005cfc7d38ac305bc3e3cd9339f7e43cb Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:02:07 +0300 Subject: [PATCH 074/107] refactor(#1375): type subagent list and tail RPC params (#1389) --- .../data/ws/SubagentRepository.kt | 30 ++++++------- .../data/ws/contract/RpcMethod.kt | 18 ++++++++ .../data/ws/contract/SubagentTailParams.kt | 10 +++++ .../data/ws/RawRpcParamsRatchetTest.kt | 2 - .../data/ws/SubagentRepositoryTest.kt | 34 ++++++++++---- .../data/ws/contract/SubagentTypesTest.kt | 44 +++++++++++++++++++ 6 files changed, 111 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/SubagentTailParams.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/SubagentTypesTest.kt 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 65ca1cb5..2c6ecb7f 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 @@ -3,6 +3,9 @@ package com.m57.hermescontrol.data.ws import android.util.Log import com.m57.hermescontrol.data.model.SubagentListResponse import com.m57.hermescontrol.data.model.SubagentTailResponse +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionIdParams +import com.m57.hermescontrol.data.ws.contract.SubagentTailParams import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -33,12 +36,11 @@ object SubagentRepository { if (sessionId.isBlank()) return null return try { val result = - HermesWsClient - .request( - WsMethods.SUBAGENT_LIST, - mapOf("session_id" to sessionId), - suppressErrorEvent = true, - ).await() + HermesWsClient.call( + RpcMethods.SUBAGENT_LIST, + SessionIdParams(sessionId), + suppressErrorEvent = true, + ) decode(result) } catch (e: Exception) { Log.w(TAG, "subagent.list request failed for session $sessionId: ${e.message}") @@ -58,18 +60,12 @@ object SubagentRepository { ): SubagentTailResponse? { // Contract (SubagentIdParams): session_id and subagent_id are required, extra keys forbidden (#1379). if (subagentId.isBlank() || sessionId.isBlank()) return null - val params = - mapOf( - "session_id" to sessionId, - "subagent_id" to subagentId, - ) val result = - HermesWsClient - .request( - WsMethods.SUBAGENT_TAIL, - params, - suppressErrorEvent = true, - ).await() + HermesWsClient.call( + RpcMethods.SUBAGENT_TAIL, + SubagentTailParams(sessionId, subagentId), + suppressErrorEvent = true, + ) return decode(result) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index 8b0266f3..fdf91765 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -331,6 +331,22 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val SUBAGENT_LIST: RpcMethod = + RpcMethod( + WsMethods.SUBAGENT_LIST, + SessionIdParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val SUBAGENT_TAIL: RpcMethod = + RpcMethod( + WsMethods.SUBAGENT_TAIL, + SubagentTailParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -374,5 +390,7 @@ object RpcMethods { CONNECTORS_TOOLS, CONNECTION_RESPOND, CONNECTORS_OPERATION_WAKE, + SUBAGENT_LIST, + SUBAGENT_TAIL, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SubagentTailParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SubagentTailParams.kt new file mode 100644 index 00000000..8ecebb25 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SubagentTailParams.kt @@ -0,0 +1,10 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SubagentTailParams( + @SerialName("session_id") val sessionId: String, + @SerialName("subagent_id") val subagentId: String, +) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index 0eef3578..3831b09f 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -322,8 +322,6 @@ class RawRpcParamsRatchetTest { "data/ws/PluginManageRepository.kt::PLUGINS_MANAGE", "data/ws/PluginRemovalRepository.kt::PLUGINS_MANAGE", "data/ws/ProjectsSource.kt::PROJECTS_LIST", - "data/ws/SubagentRepository.kt::SUBAGENT_LIST", - "data/ws/SubagentRepository.kt::SUBAGENT_TAIL", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt index f1559c81..d44eb665 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt @@ -3,13 +3,17 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.model.SubagentListItem import com.m57.hermescontrol.data.model.SubagentListResponse import com.m57.hermescontrol.data.model.SubagentTailResponse -import io.mockk.every +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionIdParams +import com.m57.hermescontrol.data.ws.contract.SubagentTailParams +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.mockkObject import io.mockk.slot import io.mockk.unmockkAll -import io.mockk.verify -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -24,17 +28,31 @@ class SubagentRepositoryTest { unmockkAll() } + @Test + fun listSubagentsPreservesSuppressedErrorsAndDecodesResult() { + val captured = slot() + mockkObject(HermesWsClient) + coEvery { HermesWsClient.call(RpcMethods.SUBAGENT_LIST, capture(captured), any(), true) } returns + buildJsonObject { put("subagents", kotlinx.serialization.json.buildJsonArray { }) } + + val response = runBlocking { SubagentRepository.listSubagents("s1") } + + assertEquals(SessionIdParams("s1"), captured.captured) + assertNotNull(response) + assertTrue(response?.subagents?.isEmpty() == true) + } + @Test fun tailSubagentSendsExactlySessionIdAndSubagentId() { // #1379: the contract forbids extra keys (max_bytes) and requires session_id. - val captured = slot>() + val captured = slot() mockkObject(HermesWsClient) - every { HermesWsClient.request(WsMethods.SUBAGENT_TAIL, capture(captured), any(), any()) } returns - CompletableDeferred(mapOf("subagent_id" to "sub-1")) + coEvery { HermesWsClient.call(RpcMethods.SUBAGENT_TAIL, capture(captured), any(), true) } returns + buildJsonObject { put("subagent_id", "sub-1") } runBlocking { SubagentRepository.tailSubagent("s1", "sub-1") } - assertEquals(mapOf("session_id" to "s1", "subagent_id" to "sub-1"), captured.captured) + assertEquals(SubagentTailParams("s1", "sub-1"), captured.captured) } @Test @@ -44,7 +62,7 @@ class SubagentRepositoryTest { assertNull(SubagentRepository.tailSubagent("", "sub-1")) assertNull(SubagentRepository.tailSubagent("s1", " ")) } - verify(exactly = 0) { HermesWsClient.request(any(), any(), any(), any()) } + coVerify(exactly = 0) { HermesWsClient.call(RpcMethods.SUBAGENT_TAIL, any(), any(), any()) } } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SubagentTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SubagentTypesTest.kt new file mode 100644 index 00000000..9c888002 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SubagentTypesTest.kt @@ -0,0 +1,44 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SubagentTypesTest { + @Test + fun listEncodesOnlySessionId() { + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionIdParams.serializer(), SessionIdParams("s1")) + assertEquals(buildJsonObject { put("session_id", "s1") }, encoded) + } + + @Test + fun tailEncodesExactlyTheTwoRequiredIds() { + val encoded = + OkHttpProvider.json.encodeToJsonElement( + SubagentTailParams.serializer(), + SubagentTailParams("s1", "sub-1"), + ) + assertEquals( + buildJsonObject { + put("session_id", "s1") + put("subagent_id", "sub-1") + }, + encoded, + ) + } + + @Test + fun descriptorsAreRegisteredAndPreserveRawResults() { + assertTrue(RpcMethods.SUBAGENT_LIST in RpcMethods.all) + assertTrue(RpcMethods.SUBAGENT_TAIL in RpcMethods.all) + assertEquals("subagent.list", RpcMethods.SUBAGENT_LIST.name) + assertEquals("subagent.tail", RpcMethods.SUBAGENT_TAIL.name) + val raw: JsonElement = buildJsonObject { put("future_field", "retained") } + assertEquals(raw, OkHttpProvider.json.decodeFromJsonElement(RpcMethods.SUBAGENT_LIST.result, raw)) + assertEquals(raw, OkHttpProvider.json.decodeFromJsonElement(RpcMethods.SUBAGENT_TAIL.result, raw)) + } +} From 9a0997408185cae048a609b7870d4fa8a521deea Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:36:35 +0300 Subject: [PATCH 075/107] refactor(#1375): type process list and kill RPC params (#1390) --- .../data/ws/contract/ProcessKillParams.kt | 10 +++ .../data/ws/contract/RpcMethod.kt | 18 ++++ .../ui/process/ProcessesViewModel.kt | 23 ++---- .../data/ws/RawRpcParamsRatchetTest.kt | 2 - .../data/ws/contract/ProcessTypesTest.kt | 34 ++++++++ .../ui/process/ProcessesViewModelTest.kt | 82 ++++++++++--------- 6 files changed, 113 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessKillParams.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessKillParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessKillParams.kt new file mode 100644 index 00000000..0d4412df --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessKillParams.kt @@ -0,0 +1,10 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ProcessKillParams( + @SerialName("session_id") val sessionId: String, + @SerialName("process_id") val processId: String, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index fdf91765..249962e9 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -347,6 +347,22 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROCESS_LIST: RpcMethod = + RpcMethod( + WsMethods.PROCESS_LIST, + SessionIdParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROCESS_KILL: RpcMethod = + RpcMethod( + WsMethods.PROCESS_KILL, + ProcessKillParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -392,5 +408,7 @@ object RpcMethods { CONNECTORS_OPERATION_WAKE, SUBAGENT_LIST, SUBAGENT_TAIL, + PROCESS_LIST, + PROCESS_KILL, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt index 6a67378b..c9047f35 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt @@ -5,7 +5,10 @@ import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.ProcessInfo import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ProcessKillParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionIdParams +import com.m57.hermescontrol.data.ws.toAny import com.m57.hermescontrol.ui.common.ToastHost import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -67,13 +70,8 @@ class ProcessesViewModel : _uiState.update { it.copy(isLoading = true, errorMessage = null) } viewModelScope.launch { try { - val result = - HermesWsClient - .request( - WsMethods.PROCESS_LIST, - mapOf("session_id" to sessionId), - ).await() - val processes = parseProcessList(result) + val result = HermesWsClient.call(RpcMethods.PROCESS_LIST, SessionIdParams(sessionId)) + val processes = parseProcessList(result.toAny()) _uiState.update { it.copy( isLoading = false, @@ -105,14 +103,7 @@ class ProcessesViewModel : _uiState.update { it.copy(killingId = processId) } viewModelScope.launch { try { - HermesWsClient - .request( - WsMethods.PROCESS_KILL, - mapOf( - "process_id" to processId, - "session_id" to sessionId, - ), - ).await() + HermesWsClient.call(RpcMethods.PROCESS_KILL, ProcessKillParams(sessionId, processId)) _uiState.update { it.copy(killingId = null, toastMessage = "Process killed") } load() } catch (e: Exception) { diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index 3831b09f..c105d60e 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -342,8 +342,6 @@ class RawRpcParamsRatchetTest { "ui/chat/ChatViewModel.kt::FILE_ATTACH", "ui/chat/ChatViewModel.kt::IMAGE_ATTACH_BYTES", "ui/chat/ChatViewModel.kt::SLASH_EXEC", - "ui/process/ProcessesViewModel.kt::PROCESS_KILL", - "ui/process/ProcessesViewModel.kt::PROCESS_LIST", ) } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt new file mode 100644 index 00000000..02544cad --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt @@ -0,0 +1,34 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProcessTypesTest { + @Test + fun killEncodesExactlySessionAndProcessIds() { + val encoded = + OkHttpProvider.json.encodeToJsonElement( + ProcessKillParams.serializer(), + ProcessKillParams(sessionId = "s1", processId = "p1"), + ) + assertEquals( + buildJsonObject { + put("session_id", "s1") + put("process_id", "p1") + }, + encoded, + ) + } + + @Test + fun descriptorsAreRegisteredWithContractNames() { + assertTrue(RpcMethods.PROCESS_LIST in RpcMethods.all) + assertTrue(RpcMethods.PROCESS_KILL in RpcMethods.all) + assertEquals("process.list", RpcMethods.PROCESS_LIST.name) + assertEquals("process.kill", RpcMethods.PROCESS_KILL.name) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/process/ProcessesViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/process/ProcessesViewModelTest.kt index 19455158..fbd39b22 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/process/ProcessesViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/process/ProcessesViewModelTest.kt @@ -3,17 +3,23 @@ package com.m57.hermescontrol.ui.process import com.m57.hermescontrol.data.model.ProcessInfo import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.data.ws.WsMethods -import io.mockk.every +import com.m57.hermescontrol.data.ws.contract.ProcessKillParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionIdParams +import io.mockk.coEvery import io.mockk.mockkObject import io.mockk.slot import io.mockk.unmockkAll -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -42,34 +48,40 @@ class ProcessesViewModelTest { @Test fun `process list parses running and exited entries`() { - val raw = - mapOf( - "processes" to - listOf( - mapOf( - "session_id" to "p1", - "command" to "python train.py --epochs 10", - "pid" to 1234, - "status" to "running", - "uptime_seconds" to 95, - "cwd" to "/work", - ), - mapOf( - "session_id" to "p2", - "command" to "echo done", - "status" to "exited", - "exit_code" to 0, - ), - ), - ) + val raw: JsonElement = + buildJsonObject { + put( + "processes", + buildJsonArray { + add( + buildJsonObject { + put("session_id", "p1") + put("command", "python train.py --epochs 10") + put("pid", 1234) + put("status", "running") + put("uptime_seconds", 95) + put("cwd", "/work") + }, + ) + add( + buildJsonObject { + put("session_id", "p2") + put("command", "echo done") + put("status", "exited") + put("exit_code", 0) + }, + ) + }, + ) + } - every { - HermesWsClient.request(WsMethods.PROCESS_LIST, any()) - } returns CompletableDeferred(raw) + val listParams = slot() + coEvery { HermesWsClient.call(RpcMethods.PROCESS_LIST, capture(listParams), any(), any()) } returns raw val vm = ProcessesViewModel() vm.load() + assertEquals(SessionIdParams("sess-1"), listParams.captured) val procs = vm.uiState.value.processes assertEquals(2, procs.size) @@ -90,26 +102,20 @@ class ProcessesViewModelTest { @Test fun `kill issues process kill with session-scoped params then refreshes`() { var listCalls = 0 - val killParamsSlot = slot>() + val killParamsSlot = slot() - every { - HermesWsClient.request(WsMethods.PROCESS_LIST, any()) - } answers { + coEvery { HermesWsClient.call(RpcMethods.PROCESS_LIST, any(), any(), any()) } answers { listCalls++ - CompletableDeferred(mapOf("processes" to emptyList())) + buildJsonObject { put("processes", JsonArray(emptyList())) } } - every { - HermesWsClient.request(WsMethods.PROCESS_KILL, capture(killParamsSlot)) - } answers { - CompletableDeferred(mapOf("killed" to true)) + coEvery { HermesWsClient.call(RpcMethods.PROCESS_KILL, capture(killParamsSlot), any(), any()) } answers { + buildJsonObject { put("killed", true) } } val vm = ProcessesViewModel() vm.kill("p1") - val killParams = killParamsSlot.captured - assertEquals("p1", killParams["process_id"]) - assertEquals("sess-1", killParams["session_id"]) + assertEquals(ProcessKillParams(sessionId = "sess-1", processId = "p1"), killParamsSlot.captured) // list is called once on init (session emitted) + once after kill refresh assertTrue(listCalls >= 2) assertNull(vm.uiState.value.killingId) From 445da9fe143c20194af5e89e060131bee3843338 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:14:13 +0300 Subject: [PATCH 076/107] refactor(#1375): type plugins.manage and projects.list RPC params (#1391) --- .../data/ws/PluginManageRepository.kt | 45 ++++++-------- .../data/ws/PluginRemovalRepository.kt | 19 +++--- .../hermescontrol/data/ws/ProjectsSource.kt | 10 ++-- .../data/ws/contract/PluginsManageParams.kt | 25 ++++++++ .../data/ws/contract/ProjectsListParams.kt | 7 +++ .../data/ws/contract/RpcMethod.kt | 18 ++++++ .../data/ws/PluginManageRepositoryTest.kt | 23 +++---- .../data/ws/PluginRemovalRepositoryTest.kt | 9 ++- .../data/ws/ProjectsSourceTest.kt | 28 ++++----- .../data/ws/RawRpcParamsRatchetTest.kt | 4 -- .../ws/contract/PluginsProjectsTypesTest.kt | 60 +++++++++++++++++++ 11 files changed, 175 insertions(+), 73 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/PluginsManageParams.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProjectsListParams.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/PluginsProjectsTypesTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt index 460e5bcf..0707cd14 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt @@ -1,8 +1,10 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.remote.OkHttpProvider -import kotlinx.coroutines.CancellationException -import kotlinx.serialization.json.Json +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.PluginsManageParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement @@ -11,23 +13,14 @@ import kotlinx.serialization.json.decodeFromJsonElement * Manages plugin settings and inspection through the gateway's canonical `plugins.manage` RPC. */ class PluginManageRepository( - private val rpcRequest: suspend (String, Map) -> Any? = { method, params -> - val deferred = HermesWsClient.request(method, params.filterValues { it != null }.mapValues { it.value ?: "" }) - try { - deferred.await() - } catch (e: CancellationException) { - deferred.cancel(e) - throw e - } - }, + private val caller: TypedRpcCaller = HermesRpcCaller, ) { suspend fun listPlugins(profile: String? = null): List { - val params = mutableMapOf("action" to "list") - if (!profile.isNullOrBlank()) { - params["profile"] = profile - } - val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params) - val element = result.toJsonElement() + val element = + caller.call( + RpcMethods.PLUGINS_MANAGE, + PluginsManageParams(action = "list", profile = profile.orNullIfBlank()), + ) val pluginsElement = (element as? JsonObject)?.get("plugins") ?: return emptyList() return OkHttpProvider.json.decodeFromJsonElement>(pluginsElement) } @@ -38,15 +31,15 @@ class PluginManageRepository( profile: String? = null, ): PluginsManageSettingsResult { val params = - mutableMapOf( - "action" to "settings", - "key" to key, - "values" to JsonObject(values), + PluginsManageParams( + action = "settings", + key = key, + values = JsonObject(values), + profile = profile.orNullIfBlank(), ) - if (!profile.isNullOrBlank()) { - params["profile"] = profile - } - val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params) - return OkHttpProvider.json.decodeFromJsonElement(result.toJsonElement()) + val result = caller.call(RpcMethods.PLUGINS_MANAGE, params) + return OkHttpProvider.json.decodeFromJsonElement(result) } + + private fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt index 92766afe..a2cea993 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt @@ -1,6 +1,9 @@ package com.m57.hermescontrol.data.ws -import kotlinx.coroutines.CancellationException +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.PluginsManageParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromJsonElement @@ -14,18 +17,10 @@ data class PluginRemovalResult( /** Removes a user-installed plugin through the gateway's canonical plugin manager. */ class PluginRemovalRepository( - private val rpcRequest: suspend (String, Map) -> Any? = { method, params -> - val deferred = HermesWsClient.request(method, params) - try { - deferred.await() - } catch (e: CancellationException) { - deferred.cancel(e) - throw e - } - }, + private val caller: TypedRpcCaller = HermesRpcCaller, ) { suspend fun remove(name: String): PluginRemovalResult { - val result = rpcRequest(WsMethods.PLUGINS_MANAGE, mapOf("action" to "remove", "name" to name)) - return Json.decodeFromJsonElement(result.toJsonElement()) + val result = caller.call(RpcMethods.PLUGINS_MANAGE, PluginsManageParams(action = "remove", name = name)) + return Json.decodeFromJsonElement(result) } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt index f1984bee..7d790234 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt @@ -3,6 +3,10 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.model.ProjectInfo import com.m57.hermescontrol.data.model.ProjectsListResponse import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.ProjectsListParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.coroutines.CancellationException import kotlinx.serialization.json.JsonObject @@ -17,14 +21,12 @@ class HermesProjectsSource( // Only ask a live socket: an offline request would be queued and force a reconnect on // every refresh. The view model refetches when the connection comes back. private val isConnected: () -> Boolean = { HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED }, - private val rpcRequest: suspend (method: String, params: Map) -> Any? = { method, params -> - HermesWsClient.request(method, params).await() - }, + private val caller: TypedRpcCaller = HermesRpcCaller, ) : ProjectsSource { override suspend fun fetchProjects(): List? { if (!isConnected()) return null return try { - val element = SessionLiveStatusDecoder.anyToJsonElement(rpcRequest(WsMethods.PROJECTS_LIST, emptyMap())) + val element = caller.call(RpcMethods.PROJECTS_LIST, ProjectsListParams) if (element is JsonObject && element.containsKey("projects")) { OkHttpProvider.json.decodeFromJsonElement(ProjectsListResponse.serializer(), element).projects } else { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/PluginsManageParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/PluginsManageParams.kt new file mode 100644 index 00000000..e970a9bd --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/PluginsManageParams.kt @@ -0,0 +1,25 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** + * `plugins.manage` params. [action] has no default so it is always encoded; every other field is + * omitted when null. [profile] is an explicit override only; otherwise WsProfileParams injects it. + */ +@Serializable +data class PluginsManageParams( + @SerialName("action") val action: String, + @SerialName("profile") val profile: String? = null, + @SerialName("key") val key: String? = null, + @SerialName("name") val name: String? = null, + @SerialName("enable") val enable: Boolean? = null, + @SerialName("identifier") val identifier: String? = null, + @SerialName("repo") val repo: String? = null, + @SerialName("catalog_name") val catalogName: String? = null, + @SerialName("force") val force: Boolean? = null, + @SerialName("ref") val ref: String? = null, + @SerialName("accept_capabilities") val acceptCapabilities: Boolean? = null, + @SerialName("values") val values: JsonObject? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProjectsListParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProjectsListParams.kt new file mode 100644 index 00000000..8b8937cc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProjectsListParams.kt @@ -0,0 +1,7 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.Serializable + +/** `projects.list` takes no explicit params; the active profile is injected by WsProfileParams. */ +@Serializable +data object ProjectsListParams diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index 249962e9..4b59ac9c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -363,6 +363,22 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PLUGINS_MANAGE: RpcMethod = + RpcMethod( + WsMethods.PLUGINS_MANAGE, + PluginsManageParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROJECTS_LIST: RpcMethod = + RpcMethod( + WsMethods.PROJECTS_LIST, + ProjectsListParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -410,5 +426,7 @@ object RpcMethods { SUBAGENT_TAIL, PROCESS_LIST, PROCESS_KILL, + PLUGINS_MANAGE, + PROJECTS_LIST, ) } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt index e861aa1a..3a7dabd8 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt @@ -16,6 +16,9 @@ import org.junit.Assert.fail import org.junit.Test class PluginManageRepositoryTest { + private fun pluginRepo(handler: suspend (String, Map) -> Any?) = + PluginManageRepository(fakeCaller(handler)) + // CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list. @Test fun `listPlugins without profile calls PLUGINS_MANAGE action list and decodes plugins list`() = @@ -44,7 +47,7 @@ class PluginManageRepositoryTest { ) val repository = - PluginManageRepository { method, params -> + pluginRepo { method, params -> capturedMethod = method capturedParams = params mockPayload @@ -80,7 +83,7 @@ class PluginManageRepositoryTest { ) val repository = - PluginManageRepository { method, params -> + pluginRepo { method, params -> capturedMethod = method capturedParams = params mockPayload @@ -98,19 +101,19 @@ class PluginManageRepositoryTest { fun `listPlugins returns empty list when plugins key is missing or response is not a JsonObject`() = runTest { val emptyObjRepo = - PluginManageRepository { _, _ -> + pluginRepo { _, _ -> OkHttpProvider.json.parseToJsonElement("{}") } assertTrue(emptyObjRepo.listPlugins().isEmpty()) val nonObjRepo = - PluginManageRepository { _, _ -> + pluginRepo { _, _ -> OkHttpProvider.json.parseToJsonElement("[]") } assertTrue(nonObjRepo.listPlugins().isEmpty()) val nullRepo = - PluginManageRepository { _, _ -> + pluginRepo { _, _ -> null } assertTrue(nullRepo.listPlugins().isEmpty()) @@ -139,7 +142,7 @@ class PluginManageRepositoryTest { ) val repository = - PluginManageRepository { method, params -> + pluginRepo { method, params -> capturedMethod = method capturedParams = params mockResponse @@ -156,7 +159,7 @@ class PluginManageRepositoryTest { assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) assertEquals("settings", capturedParams["action"]) assertEquals("camofox", capturedParams["key"]) - assertEquals(JsonObject(valuesToSave), capturedParams["values"]) + assertEquals(mapOf("port" to 9222, "headless" to true), capturedParams["values"]) assertFalse(capturedParams.containsKey("profile")) assertTrue(result.ok) @@ -186,7 +189,7 @@ class PluginManageRepositoryTest { ) val repository = - PluginManageRepository { method, params -> + pluginRepo { method, params -> capturedMethod = method capturedParams = params mockResponse @@ -204,7 +207,7 @@ class PluginManageRepositoryTest { mapOf( "action" to "settings", "key" to "camofox", - "values" to JsonObject(valuesToSave), + "values" to mapOf("port" to 8080), "profile" to "staging", ), capturedParams, @@ -222,7 +225,7 @@ class PluginManageRepositoryTest { fun `saveSettings throws SerializationException when response is malformed`() = runTest { val repository = - PluginManageRepository { _, _ -> + pluginRepo { _, _ -> OkHttpProvider.json.parseToJsonElement("{\"ok\": \"not-a-boolean\"}") } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt index 81a6d21f..1c1f6e50 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt @@ -9,13 +9,16 @@ import org.junit.Assert.assertTrue import org.junit.Test class PluginRemovalRepositoryTest { + private fun removalRepo(handler: suspend (String, Map) -> Any?) = + PluginRemovalRepository(fakeCaller(handler)) + @Test fun `remove sends scoped gateway action and decodes success`() = runTest { var method = "" var params: Map = emptyMap() val repository = - PluginRemovalRepository { requestedMethod, requestedParams -> + removalRepo { requestedMethod, requestedParams -> method = requestedMethod params = requestedParams Json.parseToJsonElement("""{"ok":true,"name":"demo"}""") @@ -33,11 +36,11 @@ class PluginRemovalRepositoryTest { @Test fun `refusal is a typed failure and missing ok cannot masquerade as success`() = runTest { - val refused = PluginRemovalRepository { _, _ -> mapOf("ok" to false, "error" to "bundled plugin") } + val refused = removalRepo { _, _ -> mapOf("ok" to false, "error" to "bundled plugin") } assertFalse(refused.remove("builtin").ok) assertEquals("bundled plugin", refused.remove("builtin").error) - val malformed = PluginRemovalRepository { _, _ -> mapOf("name" to "demo") } + val malformed = removalRepo { _, _ -> mapOf("name" to "demo") } try { malformed.remove("demo") throw AssertionError("Missing ok must fail decoding") diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt index f17be50b..a21f80aa 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt @@ -8,12 +8,17 @@ import org.junit.Assert.assertTrue import org.junit.Test class ProjectsSourceTest { + private fun projectsSource( + connected: Boolean = true, + handler: suspend (String, Map) -> Any?, + ) = HermesProjectsSource(isConnected = { connected }, caller = fakeCaller(handler)) + @Test fun `decodes the untyped rpc map`() = runTest { var calledMethod: String? = null val source = - HermesProjectsSource(isConnected = { true }) { method, _ -> + projectsSource { method, _ -> calledMethod = method mapOf( "projects" to @@ -49,13 +54,10 @@ class ProjectsSourceTest { runTest { var calls = 0 val source = - HermesProjectsSource( - rpcRequest = { _, _ -> - calls++ - mapOf("projects" to emptyList()) - }, - isConnected = { false }, - ) + projectsSource(connected = false) { _, _ -> + calls++ + mapOf("projects" to emptyList()) + } assertNull(source.fetchProjects()) assertEquals("a disconnected fetch must not queue an RPC", 0, calls) @@ -65,10 +67,10 @@ class ProjectsSourceTest { fun `rpc failure or unexpected payload yields null`() = runTest { assertNull( - HermesProjectsSource(isConnected = { true }) { _, _ -> error("method not found") }.fetchProjects(), + projectsSource { _, _ -> error("method not found") }.fetchProjects(), ) - assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> "nope" }.fetchProjects()) - assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> null }.fetchProjects()) + assertNull(projectsSource { _, _ -> "nope" }.fetchProjects()) + assertNull(projectsSource { _, _ -> null }.fetchProjects()) } @Test @@ -76,9 +78,7 @@ class ProjectsSourceTest { runTest { val thrown = runCatching { - HermesProjectsSource( - isConnected = { true }, - ) { _, _ -> throw CancellationException("gone") }.fetchProjects() + projectsSource { _, _ -> throw CancellationException("gone") }.fetchProjects() }.exceptionOrNull() assertTrue(thrown is CancellationException) } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index c105d60e..6768ad61 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -318,10 +318,6 @@ class RawRpcParamsRatchetTest { listOf( "data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES", "data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS", - "data/ws/PluginManageRepository.kt::PLUGINS_MANAGE", - "data/ws/PluginManageRepository.kt::PLUGINS_MANAGE", - "data/ws/PluginRemovalRepository.kt::PLUGINS_MANAGE", - "data/ws/ProjectsSource.kt::PROJECTS_LIST", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/PluginsProjectsTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/PluginsProjectsTypesTest.kt new file mode 100644 index 00000000..8e1de470 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/PluginsProjectsTypesTest.kt @@ -0,0 +1,60 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PluginsProjectsTypesTest { + private fun encode(params: PluginsManageParams) = + OkHttpProvider.json.encodeToJsonElement(PluginsManageParams.serializer(), params) + + @Test + fun listEncodesOnlyAction() { + assertEquals(buildJsonObject { put("action", "list") }, encode(PluginsManageParams(action = "list"))) + } + + @Test + fun removeEncodesActionAndName() { + assertEquals( + buildJsonObject { + put("action", "remove") + put("name", "demo") + }, + encode(PluginsManageParams(action = "remove", name = "demo")), + ) + } + + @Test + fun settingsEncodesKeyValuesAndExplicitProfile() { + val values = buildJsonObject { put("port", JsonPrimitive(8080)) } + assertEquals( + buildJsonObject { + put("action", "settings") + put("profile", "staging") + put("key", "camofox") + put("values", values) + }, + encode(PluginsManageParams(action = "settings", key = "camofox", values = values, profile = "staging")), + ) + } + + @Test + fun projectsListEncodesAsEmptyObject() { + assertEquals( + buildJsonObject { }, + OkHttpProvider.json.encodeToJsonElement(ProjectsListParams.serializer(), ProjectsListParams), + ) + } + + @Test + fun descriptorsAreRegisteredWithContractNames() { + assertTrue(RpcMethods.PLUGINS_MANAGE in RpcMethods.all) + assertTrue(RpcMethods.PROJECTS_LIST in RpcMethods.all) + assertEquals("plugins.manage", RpcMethods.PLUGINS_MANAGE.name) + assertEquals("projects.list", RpcMethods.PROJECTS_LIST.name) + } +} From cb0463aefc84c23498e8b6f5b76fe967db3af400 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:42:01 +0300 Subject: [PATCH 077/107] refactor(#1375): type profiles.list and profiles.configure RPC params (#1392) --- .../ws/contract/ProfilesConfigureParams.kt | 16 ++++ .../data/ws/contract/ProfilesListParams.kt | 7 ++ .../data/ws/contract/RpcMethod.kt | 18 ++++ .../hermescontrol/ui/bots/BotsViewModel.kt | 83 ++++++++++--------- .../ui/bots/group/GroupChatViewModel.kt | 60 ++++++-------- .../data/ws/RawRpcParamsRatchetTest.kt | 8 -- .../data/ws/contract/ProfilesTypesTest.kt | 54 ++++++++++++ 7 files changed, 164 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesConfigureParams.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesListParams.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProfilesTypesTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesConfigureParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesConfigureParams.kt new file mode 100644 index 00000000..88807989 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesConfigureParams.kt @@ -0,0 +1,16 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** + * `profiles.configure` params for the sections the app writes: `ui_meta` and the bot `soul`. + * `profile` is not a field: WsProfileParams injects it when the method is profile scoped. + */ +@Serializable +data class ProfilesConfigureParams( + @SerialName("name") val name: String? = null, + @SerialName("ui_meta") val uiMeta: JsonObject? = null, + @SerialName("soul") val soul: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesListParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesListParams.kt new file mode 100644 index 00000000..0b5f7dda --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesListParams.kt @@ -0,0 +1,7 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.Serializable + +/** `profiles.list` takes no explicit params here; the active profile is injected by WsProfileParams. */ +@Serializable +data object ProfilesListParams diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index 4b59ac9c..c2020fb4 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -379,6 +379,22 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROFILES_LIST: RpcMethod = + RpcMethod( + WsMethods.PROFILES_LIST, + ProfilesListParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROFILES_CONFIGURE: RpcMethod = + RpcMethod( + WsMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -428,5 +444,7 @@ object RpcMethods { PROCESS_KILL, PLUGINS_MANAGE, PROJECTS_LIST, + PROFILES_LIST, + PROFILES_CONFIGURE, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt index 124af95b..d7439f2c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt @@ -16,7 +16,9 @@ import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams +import com.m57.hermescontrol.data.ws.contract.ProfilesListParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.ui.common.ToastHost import kotlinx.coroutines.CoroutineDispatcher @@ -29,6 +31,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement @@ -243,14 +247,8 @@ class BotsViewModel( // First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars). var profilesWithMeta: List? = null try { - val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await() - val jsonElement = - when (rpcResult) { - is JsonElement -> rpcResult - null -> null - else -> rpcResult.toJsonElement() - } - if (jsonElement != null) { + val jsonElement = HermesWsClient.call(RpcMethods.PROFILES_LIST, ProfilesListParams) + if (jsonElement !is JsonNull) { val resp = OkHttpProvider.json.decodeFromJsonElement(jsonElement) if (!resp.profiles.isNullOrEmpty()) { profilesWithMeta = resp.profiles @@ -362,7 +360,13 @@ class BotsViewModel( ), ) val botSoul = composeBotSoul(name, title, description) - wsClientConfigureBot(name, botMeta, soul = botSoul) + // Fire-and-forget as before: the metadata write never gated the reload or onSuccess. + launch { + try { + wsClientConfigureBot(name, botMeta, soul = botSoul) + } catch (_: Exception) { + } + } loadBots() onSuccess() } else { @@ -396,7 +400,7 @@ class BotsViewModel( ), ) try { - wsClientConfigureBot(name, updatedMeta).await() + wsClientConfigureBot(name, updatedMeta) } catch (_: Exception) { } loadBots() @@ -440,7 +444,7 @@ class BotsViewModel( group = existingGroups.firstOrNull() ?: groupName, ) try { - wsClientConfigureBot(name, updatedMeta).await() + wsClientConfigureBot(name, updatedMeta) } catch (_: Exception) { } } @@ -475,14 +479,13 @@ class BotsViewModel( rooms = updatedRooms, deleted = updatedDeleted, ) - HermesWsClient - .request( - WsMethods.PROFILES_CONFIGURE, - mapOf( - "name" to defaultProfile.name, - "ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()), - ), - ).await() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = mapOf("hermes-bots-groups" to newSnapshot.toMap()).toJsonObject(), + ), + ) } } catch (_: Exception) { } @@ -509,7 +512,7 @@ class BotsViewModel( group = filtered.firstOrNull(), ) try { - wsClientConfigureBot(bot.name, updatedMeta).await() + wsClientConfigureBot(bot.name, updatedMeta) } catch (_: Exception) { } } @@ -540,14 +543,13 @@ class BotsViewModel( rooms = updatedRooms, deleted = deletedMap, ) - HermesWsClient - .request( - WsMethods.PROFILES_CONFIGURE, - mapOf( - "name" to defaultProfile.name, - "ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()), - ), - ).await() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = mapOf("hermes-bots-groups" to newSnapshot.toMap()).toJsonObject(), + ), + ) } } } catch (_: Exception) { @@ -575,11 +577,11 @@ class BotsViewModel( return lines.joinToString("\n") } - private fun wsClientConfigureBot( + private suspend fun wsClientConfigureBot( name: String, meta: BotRosterMeta, soul: String? = null, - ): kotlinx.coroutines.CompletableDeferred { + ) { val metaMap = buildMap { meta.title?.let { put("title", it) } @@ -599,16 +601,15 @@ class BotsViewModel( } } - val params = - buildMap { - put("name", name) - put("ui_meta", mapOf("hermes-bots" to metaMap)) - soul?.let { put("soul", it) } - } - - return HermesWsClient.request( - WsMethods.PROFILES_CONFIGURE, - params, + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = name, + uiMeta = mapOf("hermes-bots" to metaMap).toJsonObject(), + soul = soul, + ), ) } + + private fun Map.toJsonObject(): JsonObject = toJsonElement() as JsonObject } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index ca8b2e5d..52940d82 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -16,7 +16,8 @@ import com.m57.hermescontrol.data.remote.safeApiCall 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.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams +import com.m57.hermescontrol.data.ws.contract.ProfilesListParams import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.data.ws.contract.SessionCreateParams @@ -34,6 +35,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement import java.util.UUID @@ -327,14 +330,8 @@ class GroupChatViewModel( private suspend fun fetchProfiles(): List { try { - val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await() - val jsonElement = - when (rpcResult) { - is JsonElement -> rpcResult - null -> null - else -> rpcResult.toJsonElement() - } - if (jsonElement != null) { + val jsonElement = HermesWsClient.call(RpcMethods.PROFILES_LIST, ProfilesListParams) + if (jsonElement !is JsonNull) { val resp = json.decodeFromJsonElement(jsonElement) if (!resp.profiles.isNullOrEmpty()) { return resp.profiles @@ -534,14 +531,13 @@ class GroupChatViewModel( val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) - HermesWsClient - .request( - WsMethods.PROFILES_CONFIGURE, - mapOf( - "name" to defaultProfile.name, - "ui_meta" to uiMetaPayload, - ), - ).await() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = uiMetaPayload.toJsonElement() as JsonObject, + ), + ) } catch (e: Exception) { Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}") } @@ -636,14 +632,13 @@ class GroupChatViewModel( val newSnapshot = existingSnapshot.copy(version = 3, updatedAt = now, rooms = updatedRooms) val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) - HermesWsClient - .request( - WsMethods.PROFILES_CONFIGURE, - mapOf( - "name" to defaultProfile.name, - "ui_meta" to uiMetaPayload, - ), - ).await() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = uiMetaPayload.toJsonElement() as JsonObject, + ), + ) return true } catch (e: Exception) { @@ -1193,14 +1188,13 @@ class GroupChatViewModel( val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) - HermesWsClient - .request( - WsMethods.PROFILES_CONFIGURE, - mapOf( - "name" to defaultProfile.name, - "ui_meta" to uiMetaPayload, - ), - ).await() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = uiMetaPayload.toJsonElement() as JsonObject, + ), + ) } catch (e: Exception) { Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}") } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index 6768ad61..64595925 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -318,14 +318,6 @@ class RawRpcParamsRatchetTest { listOf( "data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES", "data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS", - "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", - "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", - "ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE", - "ui/bots/BotsViewModel.kt::PROFILES_LIST", - "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", - "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", - "ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE", - "ui/bots/group/GroupChatViewModel.kt::PROFILES_LIST", "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProfilesTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProfilesTypesTest.kt new file mode 100644 index 00000000..1083f9bc --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProfilesTypesTest.kt @@ -0,0 +1,54 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProfilesTypesTest { + private fun encode(params: ProfilesConfigureParams) = + OkHttpProvider.json.encodeToJsonElement(ProfilesConfigureParams.serializer(), params) + + @Test + fun configureEncodesNameAndUiMetaOnly() { + val meta = buildJsonObject { put("hermes-bots-groups", buildJsonObject { put("version", 3) }) } + assertEquals( + buildJsonObject { + put("name", "default") + put("ui_meta", meta) + }, + encode(ProfilesConfigureParams(name = "default", uiMeta = meta)), + ) + } + + @Test + fun configureIncludesSoulWhenPresent() { + val meta = buildJsonObject { put("hermes-bots", buildJsonObject { put("title", "Scout") }) } + assertEquals( + buildJsonObject { + put("name", "scout") + put("ui_meta", meta) + put("soul", "# Scout") + }, + encode(ProfilesConfigureParams(name = "scout", uiMeta = meta, soul = "# Scout")), + ) + } + + @Test + fun listEncodesAsEmptyObject() { + assertEquals( + buildJsonObject { }, + OkHttpProvider.json.encodeToJsonElement(ProfilesListParams.serializer(), ProfilesListParams), + ) + } + + @Test + fun descriptorsAreRegisteredWithContractNames() { + assertTrue(RpcMethods.PROFILES_LIST in RpcMethods.all) + assertTrue(RpcMethods.PROFILES_CONFIGURE in RpcMethods.all) + assertEquals("profiles.list", RpcMethods.PROFILES_LIST.name) + assertEquals("profiles.configure", RpcMethods.PROFILES_CONFIGURE.name) + } +} From 1456e642e32b1b1e065812e7a3ed871ab798ac04 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:40:24 +0300 Subject: [PATCH 078/107] refactor(#1375): type config.set, config.get, model.options and client.capabilities (#1393) --- .../hermescontrol/data/ws/HermesWsClient.kt | 8 +- .../data/ws/ModelOptionsRepository.kt | 15 +-- .../data/ws/contract/ConfigModel.kt | 41 +++++++ .../data/ws/contract/RpcMethod.kt | 36 ++++++ .../ui/chat/ChatModelSwitchDelegate.kt | 62 ++++------ .../hermescontrol/ui/chat/ChatViewModel.kt | 32 +++--- .../data/ws/ModelOptionsRepositoryTest.kt | 3 +- .../data/ws/RawRpcParamsRatchetTest.kt | 8 -- .../data/ws/contract/ConfigModelTypesTest.kt | 102 +++++++++++++++++ .../ui/chat/ChatModelSwitchDelegateTest.kt | 85 +++++++------- .../ui/chat/ChatViewModelTest.kt | 46 +++----- .../ui/chat/SlashCommandDispatchRpcTest.kt | 108 ++++++++---------- 12 files changed, 339 insertions(+), 207 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConfigModel.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConfigModelTypesTest.kt 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 a9238ebc..4175356e 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 @@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.await import com.m57.hermescontrol.data.session.ActiveSessionHolder +import com.m57.hermescontrol.data.ws.contract.ClientCapabilitiesParams import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams import com.m57.hermescontrol.data.ws.contract.RpcMethod import com.m57.hermescontrol.data.ws.contract.RpcMethods @@ -364,8 +365,11 @@ object HermesWsClient { // Older gateways may reject this method; that is harmless. runCatching { send( - WsMethods.CLIENT_CAPABILITIES, - mapOf("server_requests" to true), + RpcMethods.CLIENT_CAPABILITIES.name, + encodeParams( + RpcMethods.CLIENT_CAPABILITIES, + ClientCapabilitiesParams(serverRequests = true), + ), onSent = { id -> capabilityRequestIds.add(id) }, queueIfDisconnected = false, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt index 90cd65b8..6509b24f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt @@ -5,6 +5,8 @@ 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.ws.contract.ModelOptionsParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods import kotlinx.coroutines.CancellationException import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive @@ -16,13 +18,8 @@ class ModelOptionsRepository( private val connected: () -> Boolean = { HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED }, - private val request: suspend (Map) -> Any? = { params -> - val deferred = HermesWsClient.request(WsMethods.MODEL_OPTIONS, params) - try { - deferred.await() - } finally { - if (!deferred.isCompleted) deferred.cancel() - } + private val request: suspend (ModelOptionsParams) -> JsonElement = { params -> + HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params) }, private val rest: suspend (Boolean) -> NetworkResult = { refresh -> safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) } @@ -32,9 +29,7 @@ class ModelOptionsRepository( currentCoroutineContext().ensureActive() if (connected()) { try { - val result = request(mapOf("refresh" to refresh, "include_unconfigured" to false)) - // request() returns raw JsonElement, unlike the pushed-event Map representation. - val json = result as? JsonElement ?: error("Invalid model.options result") + val json = request(ModelOptionsParams(refresh = refresh, includeUnconfigured = false)) return NetworkResult.Success(OkHttpProvider.json.decodeFromJsonElement(json)) } catch (e: CancellationException) { // A disconnected socket may cancel the independent RPC deferred. Only fall back diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConfigModel.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConfigModel.kt new file mode 100644 index 00000000..c9721c89 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConfigModel.kt @@ -0,0 +1,41 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * `config.set` params. [key] and [value] are always encoded (no defaults); the rest are omitted + * when null. `profile` is injected by WsProfileParams, so it is not a field here. + */ +@Serializable +data class ConfigSetParams( + @SerialName("key") val key: String, + @SerialName("value") val value: String, + @SerialName("session_id") val sessionId: String? = null, + @SerialName("scope") val scope: String? = null, + @SerialName("confirm_expensive_model") val confirmExpensiveModel: Boolean? = null, +) + +@Serializable +data class ConfigGetParams( + @SerialName("key") val key: String, + @SerialName("session_id") val sessionId: String? = null, + @SerialName("cwd") val cwd: String? = null, +) + +/** + * `model.options` params. Flags are `Boolean?` so an explicit `false` is still sent while an + * unset flag is omitted (the client Json has `encodeDefaults = false`). + */ +@Serializable +data class ModelOptionsParams( + @SerialName("session_id") val sessionId: String? = null, + @SerialName("explicit_only") val explicitOnly: Boolean? = null, + @SerialName("include_unconfigured") val includeUnconfigured: Boolean? = null, + @SerialName("refresh") val refresh: Boolean? = null, +) + +@Serializable +data class ClientCapabilitiesParams( + @SerialName("server_requests") val serverRequests: Boolean, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index c2020fb4..68bbc53e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -395,6 +395,38 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val CONFIG_SET: RpcMethod = + RpcMethod( + WsMethods.CONFIG_SET, + ConfigSetParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val CONFIG_GET: RpcMethod = + RpcMethod( + WsMethods.CONFIG_GET, + ConfigGetParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val MODEL_OPTIONS: RpcMethod = + RpcMethod( + WsMethods.MODEL_OPTIONS, + ModelOptionsParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val CLIENT_CAPABILITIES: RpcMethod = + RpcMethod( + WsMethods.CLIENT_CAPABILITIES, + ClientCapabilitiesParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -446,5 +478,9 @@ object RpcMethods { PROJECTS_LIST, PROFILES_LIST, PROFILES_CONFIGURE, + CONFIG_SET, + CONFIG_GET, + MODEL_OPTIONS, + CLIENT_CAPABILITIES, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt index 092e8a9a..9840d544 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt @@ -12,7 +12,8 @@ import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.ws.ModelCatalogStore -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ConfigSetParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.data.ws.toAny import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher @@ -51,7 +52,7 @@ class ChatModelSwitchDelegate( private val ioDispatcher: CoroutineDispatcher, private val uiState: MutableStateFlow, private val runtimeSessionId: () -> String?, - private val wsSend: (method: String, params: Map, onSent: ((String) -> Unit)?) -> Unit, + private val wsSend: (params: ConfigSetParams, onSent: ((String) -> Unit)?) -> Unit, private val trackRequest: (id: String, method: String) -> Unit, private val addAssistantMessage: (text: String) -> Unit, private val handleSlashCommand: (command: String) -> Unit, @@ -64,7 +65,7 @@ class ChatModelSwitchDelegate( { refresh -> ModelCatalogStore.shared.ensureLoaded(forceRefresh = refresh) }, private val getPinnedModels: () -> List = { AuthManager.getPinnedModels() }, private val savePinnedModels: (List) -> Unit = { AuthManager.savePinnedModels(it) }, - private val wsRequest: (suspend (method: String, params: Map) -> Any?)? = null, + private val wsRequest: (suspend (params: ConfigSetParams) -> JsonElement)? = null, ) { private val pendingModelSwitchRequests = ConcurrentHashMap() private val pendingFastSwitchRequests = ConcurrentHashMap() @@ -300,20 +301,15 @@ class ChatModelSwitchDelegate( unconfirmedTargetModel = spec val switchSeq = ++modelSwitchSequence val params = - mutableMapOf( - "key" to "model", - "value" to spec, - "session_id" to sessionId, + ConfigSetParams( + key = "model", + value = spec, + sessionId = sessionId, + confirmExpensiveModel = true.takeIf { confirmExpensive }, ) - if (confirmExpensive) { - params["confirm_expensive_model"] = true - } scope.launch(ioDispatcher) { - wsSend( - WsMethods.CONFIG_SET, - params, - ) { id -> - trackRequest(id, WsMethods.CONFIG_SET) + wsSend(params) { id -> + trackRequest(id, RpcMethods.CONFIG_SET.name) pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel, switchSeq) } } @@ -408,14 +404,13 @@ class ChatModelSwitchDelegate( uiState.update { it.copy(isFastModeChanging = true) } scope.launch(ioDispatcher) { wsSend( - WsMethods.CONFIG_SET, - mapOf( - "key" to "fast", - "value" to if (target) "fast" else "normal", - "session_id" to sessionId, + ConfigSetParams( + key = "fast", + value = if (target) "fast" else "normal", + sessionId = sessionId, ), ) { id -> - trackRequest(id, WsMethods.CONFIG_SET) + trackRequest(id, RpcMethods.CONFIG_SET.name) pendingFastSwitchRequests[id] = ActiveFastSwitch(target) } } @@ -539,18 +534,18 @@ class ChatModelSwitchDelegate( scope.launch(ioDispatcher) { try { val params = - mapOf( - "key" to "reasoning", - "value" to level, - "session_id" to sessionId, - "scope" to scopeName, + ConfigSetParams( + key = "reasoning", + value = level, + sessionId = sessionId, + scope = scopeName, ) val result = if (wsRequest != null) { - wsRequest.invoke(WsMethods.CONFIG_SET, params) + wsRequest.invoke(params) } else { - wsSend(WsMethods.CONFIG_SET, params) { reqId -> - trackRequest(reqId, WsMethods.CONFIG_SET) + wsSend(params) { reqId -> + trackRequest(reqId, RpcMethods.CONFIG_SET.name) } null } @@ -558,14 +553,7 @@ class ChatModelSwitchDelegate( if (opSeq == reasoningOpSequence && capturedScope == dataScopeFlow.value) { val ackLevel = if (wsRequest != null) { - // HermesWsClient.request() completes with the RAW JsonElement. - // Tests and a few injected callers may provide an already-decoded map. - val map = - when (result) { - is JsonElement -> result.toAny() as? Map<*, *> - is Map<*, *> -> result - else -> null - } + val map = result?.toAny() as? Map<*, *> val resKey = map?.get("key") as? String val resVal = map?.get("value") as? String if (resKey != "reasoning" || resVal.isNullOrEmpty()) { 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 f1dffe6a..b2d7721d 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 @@ -37,6 +37,8 @@ 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.contract.ConfigGetParams +import com.m57.hermescontrol.data.ws.contract.ConfigSetParams import com.m57.hermescontrol.data.ws.contract.PromptBtwParams import com.m57.hermescontrol.data.ws.contract.RpcMethod import com.m57.hermescontrol.data.ws.contract.RpcMethods @@ -694,13 +696,13 @@ class ChatViewModel( ioDispatcher = ioDispatcher, uiState = _uiState, runtimeSessionId = { runtimeSessionId }, - wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) }, + wsSend = { params, onSent -> wsClient.send(RpcMethods.CONFIG_SET, params, onSent) }, trackRequest = { id, method -> trackRequest(id, method) }, addAssistantMessage = { text -> addAssistantMessage(text) }, handleSlashCommand = { cmd -> handleSlashCommand(cmd) }, fetchContextUsage = { fetchContextUsage() }, onModelSwitchInitiated = { onModelSwitchInitiated() }, - wsRequest = { method, params -> wsClient.request(method, params).await() }, + wsRequest = { params -> wsClient.call(RpcMethods.CONFIG_SET, params) }, ).apply { attachScopeObserver(viewModelScope) } @@ -3846,14 +3848,10 @@ class ChatViewModel( viewModelScope.launch(ioDispatcher) { try { val result = - wsClient - .request( - WsMethods.CONFIG_GET, - mapOf( - "key" to "reasoning", - "session_id" to sessionId, - ), - ).await() + wsClient.call( + RpcMethods.CONFIG_GET, + ConfigGetParams(key = "reasoning", sessionId = sessionId), + ) val map = rpcResultMap(result) ?: error("Invalid reasoning config.get response") val value = (map["value"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown" val display = (map["display"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown" @@ -3901,13 +3899,13 @@ class ChatViewModel( viewModelScope.launch(ioDispatcher) { try { val params = - buildMap { - put("key", "reasoning") - put("value", parsed.value) - put("session_id", sessionId) - parsed.scopeName?.let { put("scope", it) } - } - val result = wsClient.request(WsMethods.CONFIG_SET, params).await() + ConfigSetParams( + key = "reasoning", + value = parsed.value, + sessionId = sessionId, + scope = parsed.scopeName, + ) + val result = wsClient.call(RpcMethods.CONFIG_SET, params) val map = rpcResultMap(result) ?: error("Invalid reasoning config.set response") val responseKey = map["key"] as? String val responseValue = map["value"] as? String diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt index 2bff3c55..06c5e2d4 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.model.ModelOptionsResponse import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.ws.contract.ModelOptionsParams import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation @@ -21,7 +22,7 @@ class ModelOptionsRepositoryTest { ModelOptionsRepository( connected = { true }, request = { params -> - assertEquals(mapOf("refresh" to true, "include_unconfigured" to false), params) + assertEquals(ModelOptionsParams(refresh = true, includeUnconfigured = false), params) Json.parseToJsonElement("""{"providers":[{"slug":"test","name":"Test","models":["a"]}]}""") }, rest = { error("REST must not run") }, diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index 64595925..0775f856 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -316,17 +316,9 @@ class RawRpcParamsRatchetTest { */ val BASELINE: List = listOf( - "data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES", - "data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS", - "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", - "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", - "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", - "ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET", "ui/chat/ChatViewModel.kt::COMMANDS_CATALOG", "ui/chat/ChatViewModel.kt::COMMAND_DISPATCH", "ui/chat/ChatViewModel.kt::COMMAND_DISPATCH", - "ui/chat/ChatViewModel.kt::CONFIG_GET", - "ui/chat/ChatViewModel.kt::CONFIG_SET", "ui/chat/ChatViewModel.kt::FILE_ATTACH", "ui/chat/ChatViewModel.kt::IMAGE_ATTACH_BYTES", "ui/chat/ChatViewModel.kt::SLASH_EXEC", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConfigModelTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConfigModelTypesTest.kt new file mode 100644 index 00000000..8f786b28 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConfigModelTypesTest.kt @@ -0,0 +1,102 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConfigModelTypesTest { + private val json = OkHttpProvider.json + + @Test + fun configSetEncodesOnlyKeyAndValueByDefault() { + assertEquals( + buildJsonObject { + put("key", "fast") + put("value", "normal") + }, + json.encodeToJsonElement(ConfigSetParams.serializer(), ConfigSetParams(key = "fast", value = "normal")), + ) + } + + @Test + fun configSetEncodesSessionScopeAndConfirmWhenPresent() { + assertEquals( + buildJsonObject { + put("key", "model") + put("value", "gpt-4o --provider openai --session") + put("session_id", "s1") + put("scope", "global") + put("confirm_expensive_model", true) + }, + json.encodeToJsonElement( + ConfigSetParams.serializer(), + ConfigSetParams( + key = "model", + value = "gpt-4o --provider openai --session", + sessionId = "s1", + scope = "global", + confirmExpensiveModel = true, + ), + ), + ) + } + + @Test + fun configGetOmitsNullSessionAndCwd() { + assertEquals( + buildJsonObject { + put("key", "reasoning") + put("session_id", "s1") + }, + json.encodeToJsonElement( + ConfigGetParams.serializer(), + ConfigGetParams(key = "reasoning", sessionId = "s1"), + ), + ) + } + + @Test + fun modelOptionsKeepsExplicitFalseAndOmitsUnsetFlags() { + assertEquals( + buildJsonObject { + put("refresh", true) + put("include_unconfigured", false) + }, + json.encodeToJsonElement( + ModelOptionsParams.serializer(), + ModelOptionsParams(refresh = true, includeUnconfigured = false), + ), + ) + assertEquals( + buildJsonObject { }, + json.encodeToJsonElement(ModelOptionsParams.serializer(), ModelOptionsParams()), + ) + } + + @Test + fun clientCapabilitiesAlwaysEncodesServerRequests() { + assertEquals( + buildJsonObject { put("server_requests", true) }, + json.encodeToJsonElement( + ClientCapabilitiesParams.serializer(), + ClientCapabilitiesParams(serverRequests = true), + ), + ) + } + + @Test + fun descriptorsAreRegisteredWithContractNames() { + listOf( + RpcMethods.CONFIG_SET to "config.set", + RpcMethods.CONFIG_GET to "config.get", + RpcMethods.MODEL_OPTIONS to "model.options", + RpcMethods.CLIENT_CAPABILITIES to "client.capabilities", + ).forEach { (method, name) -> + assertTrue("$name not in RpcMethods.all", method in RpcMethods.all) + assertEquals(name, method.name) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt index 94370290..0e4dacdc 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt @@ -8,7 +8,7 @@ import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkResult -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ConfigSetParams import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -18,6 +18,8 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -31,8 +33,7 @@ class ChatModelSwitchDelegateTest { private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "sess-1", currentSessionModel = "anthropic/claude-3")) private var runtimeId: String? = "runtime-1" - private val sentMethods = mutableListOf() - private val sentParams = mutableListOf>() + private val sentParams = mutableListOf() private val slashCommands = mutableListOf() private val assistantMessages = mutableListOf() private var contextRefetched = 0 @@ -71,10 +72,9 @@ class ChatModelSwitchDelegateTest { ioDispatcher = testDispatcher, uiState = uiState, runtimeSessionId = { runtimeId }, - wsSend = { method, params, onSent -> - sentMethods.add(method) + wsSend = { params, onSent -> sentParams.add(params) - onSent?.invoke("req-${sentMethods.size}") + onSent?.invoke("req-${sentParams.size}") }, trackRequest = { _, _ -> }, addAssistantMessage = { assistantMessages.add(it) }, @@ -304,8 +304,9 @@ class ChatModelSwitchDelegateTest { delegate.handleModelSwitch("/MODEL gpt-4o --provider openai --session") advanceUntilIdle() - assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods) - assertEquals("gpt-4o --provider openai --session", sentParams.first()["value"]) + assertEquals(1, sentParams.size) + assertEquals("model", sentParams.first().key) + assertEquals("gpt-4o --provider openai --session", sentParams.first().value) delegate.handleConfigSetResult( id = "req-1", @@ -320,8 +321,8 @@ class ChatModelSwitchDelegateTest { delegate.confirmModelSwitchExpensive() advanceUntilIdle() - assertEquals(2, sentMethods.size) - assertEquals(true, sentParams[1]["confirm_expensive_model"]) + assertEquals(2, sentParams.size) + assertEquals(true, sentParams[1].confirmExpensiveModel) } @Test @@ -366,7 +367,6 @@ class ChatModelSwitchDelegateTest { // Switch to model with fast capability delegate.sendSlashModel("openai", "gpt-4o") advanceUntilIdle() - sentMethods.clear() sentParams.clear() assertTrue(uiState.value.currentModelCapabilities?.fast == true) @@ -377,10 +377,10 @@ class ChatModelSwitchDelegateTest { advanceUntilIdle() assertTrue(uiState.value.isFastModeChanging) assertFalse(uiState.value.fastMode) // Still unconfirmed! - assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods) - assertEquals("fast", sentParams.first()["key"]) - assertEquals("fast", sentParams.first()["value"]) - assertEquals("runtime-1", sentParams.first()["session_id"]) + assertEquals(1, sentParams.size) + assertEquals("fast", sentParams.first().key) + assertEquals("fast", sentParams.first().value) + assertEquals("runtime-1", sentParams.first().sessionId) // Backend acknowledges delegate.handleConfigSetResult("req-1", mapOf("key" to "fast", "value" to "fast")) @@ -391,7 +391,7 @@ class ChatModelSwitchDelegateTest { delegate.toggleFastMode() advanceUntilIdle() assertTrue(uiState.value.isFastModeChanging) - assertEquals("normal", sentParams.last()["value"]) + assertEquals("normal", sentParams.last().value) delegate.handleConfigSetResult("req-2", mapOf("key" to "fast", "value" to "normal")) assertFalse(uiState.value.fastMode) @@ -405,7 +405,6 @@ class ChatModelSwitchDelegateTest { advanceUntilIdle() delegate.sendSlashModel("openai", "gpt-4o") advanceUntilIdle() - sentMethods.clear() sentParams.clear() assertTrue(uiState.value.currentModelCapabilities?.fast == true) @@ -420,10 +419,10 @@ class ChatModelSwitchDelegateTest { assertEquals(false, uiState.value.currentModelCapabilities?.fast) // Further toggle attempts are no-ops - val sentCount = sentMethods.size + val sentCount = sentParams.size delegate.toggleFastMode() advanceUntilIdle() - assertEquals(sentCount, sentMethods.size) + assertEquals(sentCount, sentParams.size) } @Test @@ -470,9 +469,8 @@ class ChatModelSwitchDelegateTest { @Test fun setReasoningLevel_awaitsRequest_updatesOnAck_andPreventsConcurrentPick() = testScope.runTest { - val gate = CompletableDeferred() - val reqMethods = mutableListOf() - val reqParams = mutableListOf>() + val gate = CompletableDeferred() + val reqParams = mutableListOf() val customDelegate = ChatModelSwitchDelegate( @@ -480,14 +478,13 @@ class ChatModelSwitchDelegateTest { ioDispatcher = testDispatcher, uiState = uiState, runtimeSessionId = { runtimeId }, - wsSend = { _, _, _ -> }, + wsSend = { _, _ -> }, trackRequest = { _, _ -> }, addAssistantMessage = {}, handleSlashCommand = {}, fetchContextUsage = {}, dataScopeFlow = dataScope, - wsRequest = { method, params -> - reqMethods.add(method) + wsRequest = { params -> reqParams.add(params) gate.await() }, @@ -503,14 +500,15 @@ class ChatModelSwitchDelegateTest { assertEquals("ultra", uiState.value.reasoningLevel) assertEquals("high", uiState.value.pendingReasoningLevel) assertNull(uiState.value.reasoningWireLevel) - assertEquals(listOf(WsMethods.CONFIG_SET), reqMethods) - assertEquals("session", reqParams.first()["scope"]) - assertEquals("high", reqParams.first()["value"]) + assertEquals(1, reqParams.size) + assertEquals("reasoning", reqParams.first().key) + assertEquals("session", reqParams.first().scope) + assertEquals("high", reqParams.first().value) // Second pick while in-flight is rejected (serialized) customDelegate.setReasoningLevel("low") runCurrent() - assertEquals(1, reqMethods.size) + assertEquals(1, reqParams.size) // Simulate session.info winning the race and publishing the fresh // authoritative wire before the RPC ACK resumes the delegate. @@ -540,13 +538,13 @@ class ChatModelSwitchDelegateTest { ioDispatcher = testDispatcher, uiState = uiState, runtimeSessionId = { runtimeId }, - wsSend = { _, _, _ -> }, + wsSend = { _, _ -> }, trackRequest = { _, _ -> }, addAssistantMessage = {}, handleSlashCommand = {}, fetchContextUsage = {}, dataScopeFlow = dataScope, - wsRequest = { _, _ -> throw RuntimeException("4002 unknown reasoning value") }, + wsRequest = { throw RuntimeException("4002 unknown reasoning value") }, ) customDelegate.setReasoningLevel("ultra") @@ -560,7 +558,7 @@ class ChatModelSwitchDelegateTest { @Test fun setReasoningLevel_whenRuntimeSessionNullOrBlank_doesNotSend() = testScope.runTest { - val reqMethods = mutableListOf() + val reqParams = mutableListOf() runtimeId = null val customDelegate = @@ -569,22 +567,22 @@ class ChatModelSwitchDelegateTest { ioDispatcher = testDispatcher, uiState = uiState, runtimeSessionId = { runtimeId }, - wsSend = { _, _, _ -> }, + wsSend = { _, _ -> }, trackRequest = { _, _ -> }, addAssistantMessage = {}, handleSlashCommand = {}, fetchContextUsage = {}, dataScopeFlow = dataScope, - wsRequest = { method, _ -> - reqMethods.add(method) - null + wsRequest = { params -> + reqParams.add(params) + JsonNull }, ) customDelegate.setReasoningLevel("high") advanceUntilIdle() - assertTrue(reqMethods.isEmpty()) + assertTrue(reqParams.isEmpty()) assertNull(uiState.value.pendingReasoningLevel) assertNull(uiState.value.reasoningLevel) } @@ -592,20 +590,20 @@ class ChatModelSwitchDelegateTest { @Test fun setReasoningLevel_staleScopeOrReset_dropsLateAck() = testScope.runTest { - val gate = CompletableDeferred() + val gate = CompletableDeferred() val customDelegate = ChatModelSwitchDelegate( scope = testScope, ioDispatcher = testDispatcher, uiState = uiState, runtimeSessionId = { runtimeId }, - wsSend = { _, _, _ -> }, + wsSend = { _, _ -> }, trackRequest = { _, _ -> }, addAssistantMessage = {}, handleSlashCommand = {}, fetchContextUsage = {}, dataScopeFlow = dataScope, - wsRequest = { _, _ -> gate.await() }, + wsRequest = { gate.await() }, ) customDelegate.setReasoningLevel("high") @@ -617,7 +615,12 @@ class ChatModelSwitchDelegateTest { assertNull(uiState.value.pendingReasoningLevel) // Late RPC response arrives - gate.complete(mapOf("key" to "reasoning", "value" to "high")) + gate.complete( + kotlinx.serialization.json.buildJsonObject { + put("key", kotlinx.serialization.json.JsonPrimitive("reasoning")) + put("value", kotlinx.serialization.json.JsonPrimitive("high")) + }, + ) advanceUntilIdle() assertNull(uiState.value.reasoningLevel) 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 a2a0be4b..10f52473 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 @@ -35,6 +35,8 @@ 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.data.ws.contract.ConfigSetParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.notification.TurnCorrelationTracker import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore @@ -1295,15 +1297,9 @@ class ChatViewModelTest { // commands). NOT command.dispatch (4018s on /model), NOT prompt.submit // (LLM would treat it as text). Capture the config.set params. val modelCalls = mutableListOf>() - every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { - val params = arg>(1) - modelCalls.add( - Triple( - params["key"] as String, - params["value"] as String, - params["session_id"] as String, - ), - ) + every { HermesWsClient.send(RpcMethods.CONFIG_SET, any(), any()) } answers { + val params = arg(1) + modelCalls.add(Triple(params.key, params.value, params.sessionId.orEmpty())) "req-cfg-${modelCalls.size}" } @@ -1315,7 +1311,7 @@ class ChatViewModelTest { "openai/gpt-4o", viewModel.uiState.value.currentSessionModel, ) - verify { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } + verify { HermesWsClient.send(RpcMethods.CONFIG_SET, any(), any()) } val call = modelCalls.firstOrNull { it.first == "model" } assertNotNull("selection must route through config.set key=model", call) assertEquals("gpt-4o --provider openai --session", call!!.second) @@ -1340,7 +1336,7 @@ class ChatViewModelTest { // (key="model"), which the gateway routes to _apply_model_switch. NOT // command.dispatch (4018s on /model) and NOT prompt.submit (LLM would // treat it as text). - verify { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } + verify { HermesWsClient.send(RpcMethods.CONFIG_SET, any(), any()) } } @Test @@ -1353,15 +1349,9 @@ class ChatViewModelTest { // slash prefix must be stripped, or parse_model_flags on the // backend won't recognize it and the hot-swap silently fails. val modelCalls = mutableListOf>() - every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { - val params = arg>(1) - modelCalls.add( - Triple( - params["key"] as String, - params["value"] as String, - params["session_id"] as String, - ), - ) + every { HermesWsClient.send(RpcMethods.CONFIG_SET, any(), any()) } answers { + val params = arg(1) + modelCalls.add(Triple(params.key, params.value, params.sessionId.orEmpty())) "req-cfg-ci-${modelCalls.size}" } @@ -1388,9 +1378,9 @@ class ChatViewModelTest { val (viewModel, sessionId) = createViewModelWithSession() var lastReqId = "" - val capturedParams = mutableListOf>() - every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { - val params = arg>(1) + val capturedParams = mutableListOf() + every { HermesWsClient.send(RpcMethods.CONFIG_SET, any(), any()) } answers { + val params = arg(1) capturedParams.add(params) val id = "req-confirm-${capturedParams.size}" lastReqId = id @@ -1403,7 +1393,7 @@ class ChatViewModelTest { advanceUntilIdle() assertEquals(1, capturedParams.size) - assertNull(capturedParams[0]["confirm_expensive_model"]) + assertNull(capturedParams[0].confirmExpensiveModel) // Backend returns confirm_required = true mockEventsFlow.emit( @@ -1429,12 +1419,12 @@ class ChatViewModelTest { assertNull(viewModel.uiState.value.modelSwitchConfirmMessage) assertEquals(2, capturedParams.size) - assertEquals(true, capturedParams[1]["confirm_expensive_model"]) + assertEquals(true, capturedParams[1].confirmExpensiveModel) assertEquals( "muse-spark-1.3-contributor-free --provider opencode-free --session", - capturedParams[1]["value"], + capturedParams[1].value, ) - assertEquals(sessionId, capturedParams[1]["session_id"]) + assertEquals(sessionId, capturedParams[1].sessionId) } @Test @@ -1443,7 +1433,7 @@ class ChatViewModelTest { val (viewModel, _) = createViewModelWithSession() var lastReqId = "" - every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { + every { HermesWsClient.send(RpcMethods.CONFIG_SET, any(), any()) } answers { val id = "req-cfg-dismiss" lastReqId = id arg<((String) -> Unit)?>(2)?.invoke(id) 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 1447a65e..5c301f69 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 @@ -12,6 +12,9 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ConfigGetParams +import com.m57.hermescontrol.data.ws.contract.ConfigSetParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore import io.mockk.coEvery @@ -21,6 +24,7 @@ import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.slot import io.mockk.unmockkAll +import io.mockk.verify import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -34,6 +38,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -271,35 +276,27 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, sessionId) = createViewModelWithSession() - val methodCalls = mutableListOf() - val paramsCalls = mutableListOf>() - every { - HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + val setCalls = mutableListOf() + coEvery { + HermesWsClient.call(RpcMethods.CONFIG_SET, capture(setCalls), any(), any()) } answers { - val result = - if (arg(0) == WsMethods.CONFIG_SET) { - kotlinx.serialization.json.buildJsonObject { - put("key", kotlinx.serialization.json.JsonPrimitive("reasoning")) - put("value", kotlinx.serialization.json.JsonPrimitive("high")) - } - } else { - kotlinx.serialization.json.JsonNull - } - CompletableDeferred(result) + kotlinx.serialization.json.buildJsonObject { + put("key", kotlinx.serialization.json.JsonPrimitive("reasoning")) + put("value", kotlinx.serialization.json.JsonPrimitive("high")) + } } vm.sendMessage("/reasoning high --global") advanceUntilIdle() - val configIndex = methodCalls.indexOf(WsMethods.CONFIG_SET) - assertTrue("expected CONFIG_SET, got $methodCalls", configIndex >= 0) - val params = paramsCalls[configIndex] - assertEquals("reasoning", params["key"]) - assertEquals("high", params["value"]) - assertEquals(sessionId, params["session_id"]) - assertEquals("global", params["scope"]) + assertEquals("expected exactly one config.set, got $setCalls", 1, setCalls.size) + val params = setCalls.single() + assertEquals("reasoning", params.key) + assertEquals("high", params.value) + assertEquals(sessionId, params.sessionId) + assertEquals("global", params.scope) assertEquals("high", vm.uiState.value.reasoningLevel) - assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls) + verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } } @Test @@ -307,33 +304,26 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, sessionId) = createViewModelWithSession() - val methodCalls = mutableListOf() - val paramsCalls = mutableListOf>() - every { - HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + val setCalls = mutableListOf() + coEvery { + HermesWsClient.call(RpcMethods.CONFIG_SET, capture(setCalls), any(), any()) } answers { - val result = - if (arg(0) == WsMethods.CONFIG_SET) { - kotlinx.serialization.json.buildJsonObject { - put("key", kotlinx.serialization.json.JsonPrimitive("reasoning")) - put("value", kotlinx.serialization.json.JsonPrimitive("show")) - } - } else { - kotlinx.serialization.json.JsonNull - } - CompletableDeferred(result) + kotlinx.serialization.json.buildJsonObject { + put("key", kotlinx.serialization.json.JsonPrimitive("reasoning")) + put("value", kotlinx.serialization.json.JsonPrimitive("show")) + } } vm.sendMessage("/reasoning show") advanceUntilIdle() - val configIndex = methodCalls.indexOf(WsMethods.CONFIG_SET) - assertTrue("expected CONFIG_SET, got $methodCalls", configIndex >= 0) - val params = paramsCalls[configIndex] - assertEquals("reasoning", params["key"]) - assertEquals("show", params["value"]) - assertEquals(sessionId, params["session_id"]) - assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls) + assertEquals("expected exactly one config.set, got $setCalls", 1, setCalls.size) + val params = setCalls.single() + assertEquals("reasoning", params.key) + assertEquals("show", params.value) + assertEquals(sessionId, params.sessionId) + assertNull(params.scope) + verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } assertEquals( "reasoning: show", vm.uiState.value.messages @@ -347,32 +337,24 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, sessionId) = createViewModelWithSession() - val methodCalls = mutableListOf() - val paramsCalls = mutableListOf>() - every { - HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + val getCalls = mutableListOf() + coEvery { + HermesWsClient.call(RpcMethods.CONFIG_GET, capture(getCalls), any(), any()) } answers { - val result = - if (arg(0) == WsMethods.CONFIG_GET) { - kotlinx.serialization.json.buildJsonObject { - put("value", kotlinx.serialization.json.JsonPrimitive("ultra")) - put("display", kotlinx.serialization.json.JsonPrimitive("hide")) - } - } else { - kotlinx.serialization.json.JsonNull - } - CompletableDeferred(result) + kotlinx.serialization.json.buildJsonObject { + put("value", kotlinx.serialization.json.JsonPrimitive("ultra")) + put("display", kotlinx.serialization.json.JsonPrimitive("hide")) + } } vm.sendMessage("/reasoning") advanceUntilIdle() - val configIndex = methodCalls.indexOf(WsMethods.CONFIG_GET) - assertTrue("expected CONFIG_GET, got $methodCalls", configIndex >= 0) - val params = paramsCalls[configIndex] - assertEquals("reasoning", params["key"]) - assertEquals(sessionId, params["session_id"]) - assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls) + assertEquals("expected exactly one config.get, got $getCalls", 1, getCalls.size) + val params = getCalls.single() + assertEquals("reasoning", params.key) + assertEquals(sessionId, params.sessionId) + verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } assertEquals( "reasoning: ultra · display hide", vm.uiState.value.messages From 26c60a10c0eb3f50e8cc70f52c99e5afe2d4dc22 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:09:34 +0300 Subject: [PATCH 079/107] refactor(#1375): type command dispatch, slash exec and attachment RPC params (#1394) --- .../hermescontrol/data/ws/HermesWsClient.kt | 14 ++- .../data/ws/contract/ChatCommands.kt | 40 +++++++ .../data/ws/contract/RpcMethod.kt | 45 ++++++++ .../hermescontrol/ui/chat/ChatViewModel.kt | 73 +++++++------ .../data/ws/RawRpcParamsRatchetTest.kt | 10 +- .../data/ws/contract/ChatCommandsTypesTest.kt | 103 ++++++++++++++++++ .../ui/chat/ChatViewModelTest.kt | 8 +- .../ui/chat/SlashCommandDispatchRpcTest.kt | 35 +++--- 8 files changed, 263 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ChatCommands.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ChatCommandsTypesTest.kt 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 4175356e..71e219b0 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 @@ -830,6 +830,17 @@ object HermesWsClient { return deferred } + /** + * Non-suspending typed twin of [request] for callers that must enqueue inside a + * non-suspending owner check and await the raw result themselves. + */ + fun requestTyped( + method: RpcMethod, + params: P, + timeoutMs: Long = REQUEST_TIMEOUT_MS, + suppressErrorEvent: Boolean = false, + ): CompletableDeferred = request(method.name, encodeParams(method, params), timeoutMs, suppressErrorEvent) + /** * Send a typed JSON-RPC request and await its deserialized result. * @@ -844,8 +855,7 @@ object HermesWsClient { timeoutMs: Long = REQUEST_TIMEOUT_MS, suppressErrorEvent: Boolean = false, ): R { - val encoded = encodeParams(method, params) - val deferred = request(method.name, encoded, timeoutMs, suppressErrorEvent) + val deferred = requestTyped(method, params, timeoutMs, suppressErrorEvent) val result = try { deferred.await() diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ChatCommands.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ChatCommands.kt new file mode 100644 index 00000000..c3f32fda --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ChatCommands.kt @@ -0,0 +1,40 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** `commands.catalog` is sent without explicit params; the active profile is injected by WsProfileParams. */ +@Serializable +data object CommandsCatalogParams + +/** `arg` is sent whenever non-null, including an empty string, exactly like the old map payload. */ +@Serializable +data class CommandDispatchParams( + @SerialName("name") val name: String, + @SerialName("arg") val arg: String? = null, + @SerialName("session_id") val sessionId: String? = null, +) + +@Serializable +data class SlashExecParams( + @SerialName("session_id") val sessionId: String, + @SerialName("command") val command: String, +) + +/** `data_url` carries the bytes for an uploaded file; `path` is for gateway-visible files. */ +@Serializable +data class FileAttachParams( + @SerialName("session_id") val sessionId: String, + @SerialName("path") val path: String? = null, + @SerialName("data_url") val dataUrl: String? = null, + @SerialName("name") val name: String? = null, +) + +/** The mobile client always sends the payload as `content_base64`; the `data` alias is not modelled. */ +@Serializable +data class ImageAttachBytesParams( + @SerialName("session_id") val sessionId: String, + @SerialName("content_base64") val contentBase64: String? = null, + @SerialName("filename") val filename: String? = null, + @SerialName("ext") val ext: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index 68bbc53e..f611d435 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -427,6 +427,46 @@ object RpcMethods { JsonElement.serializer(), ) + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val COMMANDS_CATALOG: RpcMethod = + RpcMethod( + WsMethods.COMMANDS_CATALOG, + CommandsCatalogParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val COMMAND_DISPATCH: RpcMethod = + RpcMethod( + WsMethods.COMMAND_DISPATCH, + CommandDispatchParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val SLASH_EXEC: RpcMethod = + RpcMethod( + WsMethods.SLASH_EXEC, + SlashExecParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val FILE_ATTACH: RpcMethod = + RpcMethod( + WsMethods.FILE_ATTACH, + FileAttachParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val IMAGE_ATTACH_BYTES: RpcMethod = + RpcMethod( + WsMethods.IMAGE_ATTACH_BYTES, + ImageAttachBytesParams.serializer(), + JsonElement.serializer(), + ) + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ val all: List> = listOf( @@ -482,5 +522,10 @@ object RpcMethods { CONFIG_GET, MODEL_OPTIONS, CLIENT_CAPABILITIES, + COMMANDS_CATALOG, + COMMAND_DISPATCH, + SLASH_EXEC, + FILE_ATTACH, + IMAGE_ATTACH_BYTES, ) } 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 b2d7721d..a0c31cbe 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 @@ -37,8 +37,12 @@ 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.contract.CommandDispatchParams +import com.m57.hermescontrol.data.ws.contract.CommandsCatalogParams import com.m57.hermescontrol.data.ws.contract.ConfigGetParams import com.m57.hermescontrol.data.ws.contract.ConfigSetParams +import com.m57.hermescontrol.data.ws.contract.FileAttachParams +import com.m57.hermescontrol.data.ws.contract.ImageAttachBytesParams import com.m57.hermescontrol.data.ws.contract.PromptBtwParams import com.m57.hermescontrol.data.ws.contract.RpcMethod import com.m57.hermescontrol.data.ws.contract.RpcMethods @@ -51,6 +55,7 @@ import com.m57.hermescontrol.data.ws.contract.SessionIdParams import com.m57.hermescontrol.data.ws.contract.SessionInterruptParams import com.m57.hermescontrol.data.ws.contract.SessionListParams import com.m57.hermescontrol.data.ws.contract.SessionResumeParams +import com.m57.hermescontrol.data.ws.contract.SlashExecParams import com.m57.hermescontrol.data.ws.contract.TypedRpcSender import com.m57.hermescontrol.data.ws.toAny import com.m57.hermescontrol.data.ws.toJsonElement @@ -3400,13 +3405,13 @@ class ChatViewModel( if (attachment.isImage) { val deferred = enqueueOwned { - wsClient.request( - WsMethods.IMAGE_ATTACH_BYTES, - mapOf( - "session_id" to owner.agentSessionId, - "content_base64" to "data:${attachment.mimeType};base64,$b64", - "filename" to attachment.name, - "ext" to attachment.fileExtension, + wsClient.requestTyped( + RpcMethods.IMAGE_ATTACH_BYTES, + ImageAttachBytesParams( + sessionId = owner.agentSessionId, + contentBase64 = "data:${attachment.mimeType};base64,$b64", + filename = attachment.name, + ext = attachment.fileExtension, ), ) } ?: return@launch @@ -3419,12 +3424,12 @@ class ChatViewModel( } 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, + wsClient.requestTyped( + RpcMethods.FILE_ATTACH, + FileAttachParams( + sessionId = owner.agentSessionId, + dataUrl = "data:${attachment.mimeType};base64,$b64", + name = attachment.name, ), ) } ?: return@launch @@ -4076,12 +4081,11 @@ class ChatViewModel( // #576). For those we fall back to slash.exec, which runs the // full COMMAND_REGISTRY through the worker. val result = - wsClient - .request( - WsMethods.COMMAND_DISPATCH, - mapOf("name" to name, "arg" to arg, "session_id" to sessionId), - ).await() - handleDispatchResult(result) + wsClient.call( + RpcMethods.COMMAND_DISPATCH, + CommandDispatchParams(name = name, arg = arg, sessionId = sessionId), + ) + handleDispatchResult(result.toAny()) } catch (e: HermesWsClient.HermesRpcException) { val msg = e.message.orEmpty() // Registry miss on command.dispatch: the backend emits exactly @@ -4093,15 +4097,14 @@ class ChatViewModel( // which routes the full CLI command set through the worker. try { val result = - wsClient - .request( - WsMethods.SLASH_EXEC, - mapOf( - "command" to "/$name${if (arg.isNotEmpty()) " $arg" else ""}", - "session_id" to sessionId, - ), - ).await() - val output = (result as? Map<*, *>)?.get("output") as? String + wsClient.call( + RpcMethods.SLASH_EXEC, + SlashExecParams( + sessionId = sessionId, + command = "/$name${if (arg.isNotEmpty()) " $arg" else ""}", + ), + ) + val output = (result.toAny() as? Map<*, *>)?.get("output") as? String if (!output.isNullOrBlank()) addAssistantMessage(output) } catch (e2: HermesWsClient.HermesRpcException) { addAssistantMessage("/$name: ${e2.message}") @@ -4173,12 +4176,11 @@ class ChatViewModel( viewModelScope.launch(ioDispatcher) { try { val result = - wsClient - .request( - WsMethods.COMMAND_DISPATCH, - mapOf("name" to "undo", "arg" to count, "session_id" to sessionId), - ).await() - handleDispatchResult(result) + wsClient.call( + RpcMethods.COMMAND_DISPATCH, + CommandDispatchParams(name = "undo", arg = count, sessionId = sessionId), + ) + handleDispatchResult(result.toAny()) } catch (e: HermesWsClient.HermesRpcException) { addAssistantMessage(e.message ?: "Failed to undo.") } catch (e: Exception) { @@ -4474,7 +4476,8 @@ class ChatViewModel( private fun fetchCommandCatalog() { viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.COMMANDS_CATALOG, + RpcMethods.COMMANDS_CATALOG, + CommandsCatalogParams, onSent = { id -> trackRequest(id, WsMethods.COMMANDS_CATALOG) }, ) } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index 0775f856..271b4c35 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -314,14 +314,6 @@ class RawRpcParamsRatchetTest { * Notice HermesWsClient.kt retains its other raw uses: `CLIENT_CAPABILITIES`, `PROMPT_SUBMIT`, * and `SESSION_REDIRECT`. */ - val BASELINE: List = - listOf( - "ui/chat/ChatViewModel.kt::COMMANDS_CATALOG", - "ui/chat/ChatViewModel.kt::COMMAND_DISPATCH", - "ui/chat/ChatViewModel.kt::COMMAND_DISPATCH", - "ui/chat/ChatViewModel.kt::FILE_ATTACH", - "ui/chat/ChatViewModel.kt::IMAGE_ATTACH_BYTES", - "ui/chat/ChatViewModel.kt::SLASH_EXEC", - ) + val BASELINE: List = emptyList() } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ChatCommandsTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ChatCommandsTypesTest.kt new file mode 100644 index 00000000..58689dae --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ChatCommandsTypesTest.kt @@ -0,0 +1,103 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatCommandsTypesTest { + private val json = OkHttpProvider.json + + @Test + fun commandsCatalogEncodesAsEmptyObject() { + assertEquals( + buildJsonObject { }, + json.encodeToJsonElement(CommandsCatalogParams.serializer(), CommandsCatalogParams), + ) + } + + @Test + fun commandDispatchKeepsEmptyArgAndOmitsNullOnes() { + assertEquals( + buildJsonObject { + put("name", "help") + put("arg", "") + put("session_id", "s1") + }, + json.encodeToJsonElement( + CommandDispatchParams.serializer(), + CommandDispatchParams(name = "help", arg = "", sessionId = "s1"), + ), + ) + assertEquals( + buildJsonObject { put("name", "help") }, + json.encodeToJsonElement(CommandDispatchParams.serializer(), CommandDispatchParams(name = "help")), + ) + } + + @Test + fun slashExecEncodesSessionAndCommand() { + assertEquals( + buildJsonObject { + put("session_id", "s1") + put("command", "/status") + }, + json.encodeToJsonElement( + SlashExecParams.serializer(), + SlashExecParams(sessionId = "s1", command = "/status"), + ), + ) + } + + @Test + fun fileAttachSendsDataUrlAndNameWithoutPath() { + assertEquals( + buildJsonObject { + put("session_id", "s1") + put("data_url", "data:text/plain;base64,QQ==") + put("name", "a.txt") + }, + json.encodeToJsonElement( + FileAttachParams.serializer(), + FileAttachParams(sessionId = "s1", dataUrl = "data:text/plain;base64,QQ==", name = "a.txt"), + ), + ) + } + + @Test + fun imageAttachBytesEncodesContentBase64FilenameAndExt() { + assertEquals( + buildJsonObject { + put("session_id", "s1") + put("content_base64", "data:image/png;base64,QQ==") + put("filename", "p.png") + put("ext", "png") + }, + json.encodeToJsonElement( + ImageAttachBytesParams.serializer(), + ImageAttachBytesParams( + sessionId = "s1", + contentBase64 = "data:image/png;base64,QQ==", + filename = "p.png", + ext = "png", + ), + ), + ) + } + + @Test + fun descriptorsAreRegisteredWithContractNames() { + listOf( + RpcMethods.COMMANDS_CATALOG to "commands.catalog", + RpcMethods.COMMAND_DISPATCH to "command.dispatch", + RpcMethods.SLASH_EXEC to "slash.exec", + RpcMethods.FILE_ATTACH to "file.attach", + RpcMethods.IMAGE_ATTACH_BYTES to "image.attach_bytes", + ).forEach { (method, name) -> + assertTrue("$name not in RpcMethods.all", method in RpcMethods.all) + assertEquals(name, method.name) + } + } +} 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 10f52473..2dbe0d6b 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 @@ -1483,7 +1483,11 @@ class ChatViewModelTest { every { HermesWsClient.request( WsMethods.COMMAND_DISPATCH, - mapOf("name" to "undo", "arg" to "2", "session_id" to sessionId), + buildJsonObject { + put("name", "undo") + put("arg", "2") + put("session_id", sessionId) + }, any(), ) } returns @@ -7238,7 +7242,7 @@ class ChatViewModelTest { verify { HermesWsClient.request(WsMethods.IMAGE_ATTACH_BYTES, any()) } assertEquals( "session_id must be forwarded to image.attach_bytes", - sessionId, + JsonPrimitive(sessionId), paramsSlot.captured["session_id"], ) assertTrue("encoded attachment cache must be cleaned", attachmentCacheDir.listFiles().isNullOrEmpty()) 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 5c301f69..029b9311 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 @@ -36,6 +36,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.JsonPrimitive import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -218,9 +219,9 @@ class SlashCommandDispatchRpcTest { val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) val params = paramsCalls[dispatchIndex] - assertEquals("help", params["name"]) - assertEquals("", params["arg"]) - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive("help"), params["name"]) + assertEquals(JsonPrimitive(""), params["arg"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) } @Test @@ -482,8 +483,8 @@ class SlashCommandDispatchRpcTest { assertTrue("expected SLASH_EXEC fallback, got $methodCalls", execIndex >= 0) assertTrue("dispatch must precede fallback", dispatchIndex < execIndex) val params = paramsCalls[execIndex] - assertEquals("/status", params["command"]) - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive("/status"), params["command"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) // And the slash.exec output must be surfaced to the user. val last = @@ -629,9 +630,9 @@ class SlashCommandDispatchRpcTest { val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) val params = paramsCalls[dispatchIndex] - assertEquals("init", params["name"]) - assertEquals("", params["arg"]) - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive("init"), params["name"]) + assertEquals(JsonPrimitive(""), params["arg"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) } @Test @@ -654,9 +655,9 @@ class SlashCommandDispatchRpcTest { val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) val params = paramsCalls[dispatchIndex] - assertEquals("init", params["name"]) - assertEquals("extra context here", params["arg"]) - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive("init"), params["name"]) + assertEquals(JsonPrimitive("extra context here"), params["arg"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) } @Test @@ -682,9 +683,9 @@ class SlashCommandDispatchRpcTest { val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) val params = paramsCalls[dispatchIndex] - assertEquals("focus", params["name"]) - assertEquals("", params["arg"]) - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive("focus"), params["name"]) + assertEquals(JsonPrimitive(""), params["arg"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) } @Test @@ -707,9 +708,9 @@ class SlashCommandDispatchRpcTest { val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) val params = paramsCalls[dispatchIndex] - assertEquals("focus", params["name"]) - assertEquals("on", params["arg"]) - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive("focus"), params["name"]) + assertEquals(JsonPrimitive("on"), params["arg"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) } @Test From 6baade0f7f6748116a73c974306e148cb7464f1f Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:22:39 +0300 Subject: [PATCH 080/107] refactor(#1375): make untyped RPC request and send internal (#1395) --- AGENTS.md | 15 +++++++++++++++ .../m57/hermescontrol/data/ws/HermesWsClient.kt | 10 ++++++++-- .../data/ws/RawRpcParamsRatchetTest.kt | 7 +++---- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d0f603b3..a9f19934 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,21 @@ explicitly disconnected on logout and reconnected after login: The singleton's `connect()` has a guard (`if connected → skip`) so it's safe to call unconditionally. +### Typed Gateway RPC + +Every gateway method in the contract (`app/src/test/resources/gateway-contract/openrpc.json`) is called through a +typed descriptor: `HermesWsClient.call(RpcMethods.X, params)` (suspending), `requestTyped(...)`, or the typed +`send(RpcMethods.X, params, onSent)`. Params are `@Serializable` classes under `data/ws/contract/`; results are +`JsonElement` passthrough or typed. To add a method: add the params class, register it in `RpcMethods` (and +`RpcMethods.all`), and `GatewayContractTest` checks it against the contract. + +- The untyped `request(String, Map)` / `send(String, Map)` are `internal`. Use them only for `gateway.ping` and the + legacy `*.respond` fallbacks (`clarify`, `sudo`, `secret`, `vault.*`) that have no contract entry. +- Optional params are `T? = null`: the client Json has `encodeDefaults = false`, so unset fields are omitted and an + explicit `false`/`""` is still sent. Do not add a `profile` field unless callers pass an explicit override; + `WsProfileParams.decorate` injects it for profile-scoped methods. +- `RawRpcParamsRatchetTest.BASELINE` must stay empty. + ### Shared Components - **`HermesScaffold`** — drawer-aware Scaffold + TopAppBar with refresh slot, 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 71e219b0..fe87906b 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 @@ -800,8 +800,11 @@ object HermesWsClient { * The deferred is completed on the matching [WsEvent.RpcResult] / * [WsEvent.RpcError], rejected after [timeoutMs] (no response), or * rejected by [rejectAllPending] when the socket closes. + * + * Untyped escape hatch, module-internal on purpose: gateway methods in the contract go through + * [call] / [requestTyped] with an `RpcMethods` descriptor. Only `gateway.ping` and tests use this. */ - fun request( + internal fun request( method: String, params: Map = emptyMap(), timeoutMs: Long = REQUEST_TIMEOUT_MS, @@ -975,9 +978,12 @@ object HermesWsClient { /** * Send a JSON-RPC request with the given [method] and optional [params]. + * + * Untyped escape hatch, module-internal on purpose: contract methods use the typed [send] overload. + * Kept for the legacy `*.respond` fallbacks (clarify, sudo, secret, vault) that have no contract entry. * @return the request id used (can be matched against [WsEvent.RpcResult]). */ - fun send( + internal fun send( method: String, params: Map = emptyMap(), onSent: ((String) -> Unit)? = null, diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt index 271b4c35..3c9fae18 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -309,10 +309,9 @@ class RawRpcParamsRatchetTest { } /** - * Multiset of remaining unmigrated raw RPC call sites in `app/src/main/java/com/m57/hermescontrol/`. - * Excludes `data/ws/HermesWsClient.kt::SESSION_EVENTS_SINCE` which is migrated to typed `RpcMethods`. - * Notice HermesWsClient.kt retains its other raw uses: `CLIENT_CAPABILITIES`, `PROMPT_SUBMIT`, - * and `SESSION_REDIRECT`. + * Multiset of remaining raw RPC call sites for contract methods in `app/src/main/java/com/m57/hermescontrol/`. + * Empty: every contract method goes through `RpcMethods`. Keep it empty; new contract methods must be typed. + * The untyped `request`/`send` stay module-internal for `gateway.ping` and the legacy `*.respond` fallbacks. */ val BASELINE: List = emptyList() } From ee1d7a0b7527f6e2ac88bee6df96c24e5e4883bd Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:42:37 +0300 Subject: [PATCH 081/107] fix(#1375): treat required profile like any required param in the contract check (#1397) --- .../java/com/m57/hermescontrol/data/ws/GatewayContract.kt | 7 +++---- .../data/ws/contract/ParamsDescriptorCheckTest.kt | 7 ++++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt index a5d9530b..3abf299e 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt @@ -103,10 +103,9 @@ object GatewayContract { } } - val missingRequired = - required.filter { reqName -> - reqName !in elementNames && reqName != "profile" - } + // No exemptions: decorate() skips injection when no profile is active, so a required `profile` + // must be declared like any other required param. + val missingRequired = required.filter { it !in elementNames } for (missing in missingRequired) { problems += "$method: missing required param '$missing'" } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt index eb4468db..c5777015 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt @@ -94,13 +94,14 @@ class ParamsDescriptorCheckTest { } @Test - fun omittingProfilePassesEvenWhenRequiredBySchema() { - // bot_relay.deliver has required: ["profile", "message"] + fun omittingRequiredProfileIsRejectedLikeAnyRequiredParam() { + // bot_relay.deliver has required: ["profile", "message"]. Profile injection is skipped when no + // profile is active, so `profile` gets no special treatment: the params class must declare it. val problems = GatewayContract.paramsDescriptorProblems( "bot_relay.deliver", DeliverMissingProfileParams.serializer().descriptor, ) - assertTrue(problems.joinToString("\n"), problems.isEmpty()) + assertEquals(1, problems.count { "missing required param 'profile'" in it }) } } From 57be85d2f7766a227b19505f01778157e32ec26a Mon Sep 17 00:00:00 2001 From: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:17:10 -0400 Subject: [PATCH 082/107] fix(#1247): keep the keyboard up through the whole mic hold (#1399) --- .../chat/components/VoiceNoteGestureTest.kt | 4 + .../ui/chat/components/ChatComposer.kt | 178 ++++++++++-------- 2 files changed, 108 insertions(+), 74 deletions(-) diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt index 5c9a80c1..86f7f60b 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt @@ -219,6 +219,10 @@ class VoiceNoteGestureTest { composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } composeTestRule.mainClock.advanceTimeBy(600) + // The strip takes the field's decoration box mid-hold; the field must + // keep its focus so the keyboard never collapses when recording starts + // (device follow-up, #1247). + composeTestRule.onNodeWithTag("chat_input").assertIsFocused() composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } composeTestRule.waitForIdle() 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 2a49d067..b73833f3 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 @@ -52,6 +52,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -137,20 +138,31 @@ fun ChatInputBar( // Attachment tray state var showAttachmentTray by remember { mutableStateOf(false) } - // The voice-note strip replaces the input field, so the IME closes the - // moment recording starts. When the strip goes away (send, delete, or - // cancel), focus returns to the input and the keyboard re-opens if it was - // up before — the next message must not need an extra tap (device - // follow-up, #1247). + // The voice-note strip renders inside the input field's decoration box, so + // the field stays composed and focused through the whole hold and the + // keyboard no longer collapses when recording starts (device follow-up, + // #1247). The restore below is only a safety net for a focus that still + // got lost (IME quirk); when the field kept focus, its IME state is left + // exactly as the user left it. val inputFocusRequester = remember { FocusRequester() } val softwareKeyboard = LocalSoftwareKeyboardController.current - var inputWasFocused by remember { mutableStateOf(false) } + var inputFocused by remember { mutableStateOf(false) } var restoreInputFocus by remember { mutableStateOf(false) } - // Snapshot BEFORE the launcher flips isRecordingVoice — by the time the - // recomposition swaps the field out, focus is already on its way to the - // root, so the effect alone cannot read it reliably. + // While a voice note records, the composer's trailing slots must not + // morph: the send/queue AnimatedVisibility content would be disposed + // under the holding finger, dropping the pointer loop (and with it the + // release). The gesture loop already ignores these slots, so hold-start + // freezes the slot layout synchronously (an effect would land a frame + // late, after the slots already collapsed) and recording-end thaws them. + val holdShowSend = remember { mutableStateOf(false) } + val holdCanInterrupt = remember { mutableStateOf(false) } + // Snapshot at hold start: whether the keyboard was up (for the restore + // safety net) and the trailing slot layout (frozen while recording, see + // above). val handleMicHoldStart = { - restoreInputFocus = inputWasFocused + restoreInputFocus = inputFocused + holdShowSend.value = hasDraft + holdCanInterrupt.value = canInterrupt onMicHoldStart() } // A hold can arm and still fail to start the recorder (permission denied, @@ -194,6 +206,10 @@ fun ChatInputBar( .apply() } } + val recording = isRecordingVoice + val composerShowSend = if (recording) holdShowSend.value else hasDraft + val composerCanInterrupt = if (recording) holdCanInterrupt.value else canInterrupt + val palette = composerPalette() BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false } @@ -347,70 +363,84 @@ fun ChatInputBar( } val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl - if (isRecordingVoice) { - VoiceNoteRecordingPanel( - slideProgress = voiceSlideProgress, - locked = isVoiceNoteLocked, - onCancel = onMicHoldCancel, - 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) - .focusRequester(inputFocusRequester) - .onFocusChanged { focusState -> - if (!isRecordingVoice) { - inputWasFocused = focusState.isFocused - } - }.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() - } + // The field stays composed while a voice note records — + // drawn transparent, with the strip overlaid on top — so + // focus and the keyboard survive the hold (device + // follow-up, #1247). The overlay is a sibling, not the + // field's decoration content: decorationBox content does + // not size the field, so the strip would be clipped to + // the empty field's line height and vanish (CI, PR + // #1351). Keystrokes mid-hold are swallowed, and the + // composer's send/stop slots stay frozen — flipping them + // under the finger would drop the hold. + Box( + modifier = + Modifier + .weight(1f) + .heightIn(min = 42.dp), + ) { + BasicTextField( + value = inputFieldValue, + onValueChange = { if (!isRecordingVoice) onInputChange(it) }, + modifier = + Modifier + .fillMaxWidth() + .alpha(if (isRecordingVoice) 0f else 1f) + .heightIn(min = 42.dp, max = 200.dp) + .padding(vertical = 4.dp) + .focusRequester(inputFocusRequester) + .onFocusChanged { focusState -> + inputFocused = focusState.isFocused + }.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 -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = + if (isInputRtl) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, + ) { + CompositionLocalProvider( + LocalLayoutDirection provides inputLayoutDirection, + ) { + if (inputFieldValue.text.isEmpty() && !isRecordingVoice) { + 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() } - }, + } + }, + ) + if (isRecordingVoice) { + // On top of the transparent field: keeps the + // strip's real 42 dp+ geometry instead of being + // clipped to the empty field's line height. + VoiceNoteRecordingPanel( + slideProgress = voiceSlideProgress, + locked = isVoiceNoteLocked, + onCancel = onMicHoldCancel, + modifier = Modifier.matchParentSize(), ) } } @@ -423,9 +453,9 @@ fun ChatInputBar( reasoningLevel = reasoningLevel, isListening = isListening, canSend = canSend, - showSend = hasDraft, + showSend = composerShowSend, onSend = onSend, - canInterrupt = canInterrupt, + canInterrupt = composerCanInterrupt, onStopGeneration = onStopGeneration, onAttachTap = { showAttachmentTray = !showAttachmentTray }, onModelTap = onModelTap, From 012440b8f789fe753f7618fcab51bdf4f0d8c93e Mon Sep 17 00:00:00 2001 From: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:21:44 -0400 Subject: [PATCH 083/107] fix(nav): back from a list-opened chat returns to that list (#1400) --- .../java/com/m57/hermescontrol/Navigation.kt | 20 +++-- .../m57/hermescontrol/NavigationController.kt | 42 ++++++++- .../hermescontrol/NavigationControllerTest.kt | 88 +++++++++++++++++++ 3 files changed, 142 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 8ec9ed90..be85a28c 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -215,14 +215,22 @@ fun MainNavigation(sessionId: String? = null) { var lastBackPressTime by remember { mutableLongStateOf(0L) } val backToastText = stringResource(R.string.press_back_again_to_exit) - // Double-back to exit on root ChatScreen + // Double-back to exit on root ChatScreen — unless the chat was opened from + // a list (History, Bots): then the first back returns to that list (Telegram + // chat-list parity) and only a further double-back exits. BackHandler(enabled = currentScreen == ChatScreen && backStack.size == 1) { - val now = System.currentTimeMillis() - if (now - lastBackPressTime < 2000L) { - (context as? Activity)?.finish() + val pendingReturn = NavigationController.chatReturnScreen + if (pendingReturn != null) { + NavigationController.consumeChatReturnScreen() + NavigationController.navigateTo(pendingReturn) } else { - lastBackPressTime = now - Toast.makeText(context, backToastText, Toast.LENGTH_SHORT).show() + val now = System.currentTimeMillis() + if (now - lastBackPressTime < 2000L) { + (context as? Activity)?.finish() + } else { + lastBackPressTime = now + Toast.makeText(context, backToastText, Toast.LENGTH_SHORT).show() + } } } diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index 466067a2..36c69835 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -33,6 +33,16 @@ object NavigationController { private set var pendingNewChatNavigation: PendingNewChatNavigation? by mutableStateOf(null) private set + + /** + * The list screen a chat was opened from (History, Bots). Back out of that + * chat returns here instead of exiting the app (Telegram chat-list parity). + * Consumed by MainNavigation's root-chat back handler; cleared by any other + * navigation so a stale target can never hijack a later back press. + */ + var chatReturnScreen: NavKey? by mutableStateOf(null) + private set + val pendingSessionId: String? get() = pendingChatNavigation?.sessionId private var nextChatNavigationRequestId = 0L @@ -45,8 +55,18 @@ object NavigationController { */ fun isPrimaryScreen(key: NavKey): Boolean = key == ChatScreen || ScreenRegistry.ALL_SCREENS.any { it.key == key } + /** Returns the armed return screen and disarms it. */ + fun consumeChatReturnScreen(): NavKey? = chatReturnScreen.also { chatReturnScreen = null } + fun navigateTo(key: NavKey) { val stack = backStack ?: return + + // Any explicit navigation invalidates the pending chat return — only a + // session/chat opened from a list arms it again (see [queueChatNavigation]). + // This must precede the dedup guard: re-selecting Chat in the drawer is + // still an explicit navigation and must not keep a stale return armed. + chatReturnScreen = null + if (stack.lastOrNull() == key) return if (key == ChatScreen) { @@ -73,11 +93,16 @@ object NavigationController { } fun openChatSession(sessionId: String) { - queueChatNavigation(sessionId, scrollToBottom = false) + // Opened from a list (History cards, search results, Bots): arm the + // return so back from the chat lands back on that list instead of + // exiting the app (Telegram chat-list parity). + queueChatNavigation(sessionId, scrollToBottom = false, returnToList = true) } fun openChatSessionFromNotification(sessionId: String) { - queueChatNavigation(sessionId, scrollToBottom = true) + // A notification open is not a list navigation: back keeps the classic + // root-chat exit flow. + queueChatNavigation(sessionId, scrollToBottom = true, returnToList = false) } fun openNewChat() { @@ -91,8 +116,18 @@ object NavigationController { private fun queueChatNavigation( sessionId: String, scrollToBottom: Boolean, + returnToList: Boolean, ) { if (sessionId.isBlank()) return + // Snapshot the origin BEFORE navigateTo resets the stack to [ChatScreen]. + val returnScreen = + if (returnToList) { + backStack + ?.lastOrNull() + ?.takeIf { it != ChatScreen && isPrimaryScreen(it) } + } else { + null + } pendingChatNavigation = PendingChatNavigation( sessionId = sessionId, @@ -100,6 +135,8 @@ object NavigationController { requestId = ++nextChatNavigationRequestId, ) navigateTo(ChatScreen) + // Set after navigateTo so the reset itself does not disarm the return. + chatReturnScreen = returnScreen } fun consumePendingChatNavigation(): PendingChatNavigation? = @@ -113,6 +150,7 @@ object NavigationController { /** Clear the stack and navigate to the given screen atomically. */ fun resetTo(screen: NavKey) { val stack = backStack ?: return + chatReturnScreen = null stack.clear() stack.add(screen) } diff --git a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt index 9579944e..774dc7d5 100644 --- a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt @@ -23,6 +23,7 @@ class NavigationControllerTest { NavigationController.backStack = null NavigationController.consumePendingSessionId() NavigationController.consumePendingNewChatNavigation() + NavigationController.consumeChatReturnScreen() } @After @@ -30,6 +31,7 @@ class NavigationControllerTest { NavigationController.backStack = null NavigationController.consumePendingSessionId() NavigationController.consumePendingNewChatNavigation() + NavigationController.consumeChatReturnScreen() } // ── Dedup guard: navigateTo with same key ────────────────────────────── @@ -171,6 +173,92 @@ class NavigationControllerTest { assertFalse(NavigationController.consumePendingChatNavigation()?.scrollToBottom == true) } + // ── Chat return screen: back from a list-opened chat returns to that list ── + + @Test + fun `openChatSession from a list arms the list as the return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + + NavigationController.openChatSession("stored-session") + + assertEquals(ChatScreen, NavigationController.backStack?.lastOrNull()) + assertEquals(HistoryScreen, NavigationController.chatReturnScreen) + assertEquals(HistoryScreen, NavigationController.consumeChatReturnScreen()) + assertNull("consume must disarm", NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `openChatSession from Bots arms Bots`() { + NavigationController.backStack = NavBackStack(BotsScreen) + + NavigationController.openChatSession("bot-session") + + assertEquals(BotsScreen, NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `openChatSession from chat arms nothing`() { + NavigationController.backStack = NavBackStack(ChatScreen) + + NavigationController.openChatSession("stored-session") + + assertNull(NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `notification open never arms a return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + + NavigationController.openChatSessionFromNotification("stored-session") + + assertEquals(ChatScreen, NavigationController.backStack?.lastOrNull()) + assertNull(NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `drawer navigation disarms a stale return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + NavigationController.openChatSession("stored-session") + assertEquals(HistoryScreen, NavigationController.chatReturnScreen) + + // User opens the drawer and picks Skills instead of pressing back. + NavigationController.navigateTo(SkillsScreen) + + assertNull("explicit navigation must invalidate the return", NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `re-tapping chat in the drawer also disarms the return`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + NavigationController.openChatSession("stored-session") + assertEquals(HistoryScreen, NavigationController.chatReturnScreen) + + // Same-screen dedup would skip the body — the clear must still run. + NavigationController.navigateTo(ChatScreen) + + assertNull(NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `openNewChat does not arm a return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + + NavigationController.openNewChat() + + assertNull(NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `resetTo disarms the return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + NavigationController.openChatSession("stored-session") + assertEquals(HistoryScreen, NavigationController.chatReturnScreen) + + NavigationController.resetTo(ChatScreen) + + assertNull(NavigationController.consumeChatReturnScreen()) + } + // ── resetTo: atomic clear + navigate ────────────────────────────────── @Test From 7a2822fadecba8906a2303d144bed93a309109d3 Mon Sep 17 00:00:00 2001 From: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:27:40 -0400 Subject: [PATCH 084/107] fix(sessions): send profile in destructive bodies and prune the asked window (#1398) Co-authored-by: M57 <41645758+Hy4ri@users.noreply.github.com> --- .../data/model/SessionListResponse.kt | 15 +++++- .../data/remote/ProfileScopeInterceptor.kt | 4 ++ .../ui/sessions/SessionsScreen.kt | 19 +++++++ .../ui/sessions/SessionsViewModel.kt | 19 ++++++- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../SessionDestructiveOpsSerializationTest.kt | 50 +++++++++++++++++++ 9 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/com/m57/hermescontrol/data/model/SessionDestructiveOpsSerializationTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index 8b391ba0..4b299abc 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -123,7 +123,10 @@ data class SessionRenameRequest( @Serializable data class BulkDeleteRequest( val ids: List, - val delete_all: Boolean = false, + // Destructive POSTs read the profile off the BODY (not the ?profile= + // query rewrite) — an unnamed bulk delete is refused with 400 on a + // multi-profile backend. Null stays valid for single-profile hosts. + val profile: String? = null, ) @Serializable @@ -134,5 +137,13 @@ data class BulkDeleteResponse( @Serializable data class PruneRequest( - val days: Int, + // Server contract: `older_than_days` (hermes_cli/web_models.py SessionPrune). + // The old `days` key was unknown to the backend, so the user's choice was + // silently dropped and the server pruned its own 90-day default. + @SerialName("older_than_days") val olderThanDays: Int, + // Destructive POSTs read the profile off the BODY (not the ?profile= query + // rewrite): on a multi-profile backend an unnamed prune is refused with + // 400 (web_routers/_common.py destructive_profile). Null stays valid for + // single-profile hosts. + val profile: String? = null, ) 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 3c407a83..3202cadc 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 @@ -33,6 +33,10 @@ object ProfileScopeInterceptor : Interceptor { // multi-profile host transcribes under the launch profile // (review, PR #1250). "/api/audio", + // Curator state is per profile (config-scoped reads) and the run + // trigger is privileged: `POST /api/curator/run` is refused with + // 400 on a multi-profile backend without ?profile=. + "/api/curator", "/api/config", "/api/credentials/pool", "/api/cron", diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 5ea87d77..804446cf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -66,6 +66,7 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -507,6 +508,24 @@ fun SessionsScreen( // (The scaffold already applies top-bar padding via its inner Box, so we // must NOT re-apply paddingValues here.) Column(modifier = Modifier.fillMaxSize()) { + // Prune can take a while on large histories — show it's working. + if (state.isPruning) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = spacing.md, vertical = spacing.sm) + .testTag("sessions_pruning_indicator"), + verticalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + Text( + text = stringResource(R.string.sessions_pruning), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } Row( modifier = Modifier diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 87363b65..65cae410 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -963,7 +963,13 @@ class SessionsViewModel( val result = safeApiCall { ApiClient.hermesApi.bulkDeleteSessions( - body = BulkDeleteRequest(ids = ids), + body = + BulkDeleteRequest( + ids = ids, + // Destructive POSTs scope by BODY profile; the + // ?profile= query rewrite never reaches them. + profile = AuthManager.activeProfileId.value, + ), ) } when (result) { @@ -1021,7 +1027,16 @@ class SessionsViewModel( val result = safeApiCall { ApiClient.hermesApi.pruneSessions( - body = PruneRequest(days = days), + body = + PruneRequest( + // The server model is `older_than_days`; the + // old `days` key was silently ignored and the + // backend pruned its own 90-day default. + olderThanDays = days, + // Destructive POSTs scope by BODY profile; the + // ?profile= query rewrite never reaches them. + profile = AuthManager.activeProfileId.value, + ), ) } when (result) { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 6ff8169f..4246ab8e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -759,6 +759,7 @@ حذف الجلسات الأقدم من عدد الأيام المحدد. حد الأيام تنقيح +جارٍ تنقيح الجلسات القديمة… فارغة هل تريد حذف الجلسات الفارغة؟ حذف %1$d من الجلسات الفارغة. سيتم الاحتفاظ بالجلسات النشطة والمؤرشفة. diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 395700da..4a3e9465 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -646,6 +646,7 @@ 지정된 일수보다 오래된 세션을 삭제합니다. 일수 기준 정리 + 오래된 세션 정리 중… 분기된 세션 세션 삭제? 정말 \"%1$s\"을(를) 삭제하시겠습니까? 되돌릴 수 없습니다. diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 34212136..032fd94e 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -730,6 +730,7 @@ 删除早于指定天数的会话。 天数阈值 清理 + 正在清理旧会话… 空会话 删除空会话? 删除 %1$d 个空会话?活动与归档会话会被保留。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 793ce2ba..a057be79 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -877,6 +877,7 @@ Delete sessions older than the specified number of days. Days threshold Prune + Pruning old sessions… Empty Delete empty sessions? Delete %1$d empty session(s). Active and archived sessions are kept. diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/SessionDestructiveOpsSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/SessionDestructiveOpsSerializationTest.kt new file mode 100644 index 00000000..a84f616a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/SessionDestructiveOpsSerializationTest.kt @@ -0,0 +1,50 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** + * Wire-shape pins for the destructive session ops. The backend reads the + * profile off these request BODIES (`SessionPrune` / `BulkDeleteSessions`) — + * the `?profile=` query rewrite never reaches them — and its prune model is + * `older_than_days` (the old `days` key was silently ignored, leaving the + * server to prune its own 90-day default). + */ +class SessionDestructiveOpsSerializationTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun pruneRequest_sendsServerFieldNames() { + val encoded = json.encodeToString(PruneRequest(olderThanDays = 7, profile = "work")) + val body = json.parseToJsonElement(encoded).jsonObject + + assertEquals("7", body["older_than_days"]!!.jsonPrimitive.content) + assertEquals("work", body["profile"]!!.jsonPrimitive.content) + // The old key must be gone: the backend ignores it. + assertFalse(body.containsKey("days")) + } + + @Test + fun pruneRequest_omitsProfileWhenUnnamed() { + val encoded = json.encodeToString(PruneRequest(olderThanDays = 30)) + val body = json.parseToJsonElement(encoded).jsonObject + + assertEquals("30", body["older_than_days"]!!.jsonPrimitive.content) + assertFalse(body.containsKey("profile")) + } + + @Test + fun bulkDeleteRequest_carriesProfile() { + val encoded = json.encodeToString(BulkDeleteRequest(ids = listOf("a", "b"), profile = "work")) + val body = json.parseToJsonElement(encoded).jsonObject + + assertEquals("work", body["profile"]!!.jsonPrimitive.content) + assertEquals(listOf("a", "b"), body["ids"]!!.jsonArray.map { it.jsonPrimitive.content }) + } +} From fc2ce3f26b276922386a1029a21c4f9430099016 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:35:01 +0300 Subject: [PATCH 085/107] fix(#1402): replace Map Retrofit types with typed DTOs (#1403) --- .../data/model/CredentialPoolAddRequest.kt | 11 ++ .../data/model/DebugShareResponse.kt | 5 + .../hermescontrol/data/model/HookResponse.kt | 16 +++ .../m57/hermescontrol/data/model/McpServer.kt | 6 + .../data/remote/HermesApiService.kt | 29 +++-- .../ui/mcp/McpServersViewModel.kt | 15 ++- .../ui/system/SystemViewModel.kt | 32 ++--- .../remote/HermesApiServiceConverterTest.kt | 114 ++++++++++++++++++ .../hermescontrol/ui/mcp/McpOwnershipTest.kt | 5 +- 9 files changed, 203 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/CredentialPoolAddRequest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceConverterTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CredentialPoolAddRequest.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CredentialPoolAddRequest.kt new file mode 100644 index 00000000..ec4f9fb9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CredentialPoolAddRequest.kt @@ -0,0 +1,11 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +/** Body for POST api/credentials/pool. `apiKey` serializes as `api_key`. */ +@Serializable +data class CredentialPoolAddRequest( + val provider: String, + val apiKey: String, + val label: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/DebugShareResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/DebugShareResponse.kt index 14be3d2d..705c5212 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/DebugShareResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/DebugShareResponse.kt @@ -9,3 +9,8 @@ data class DebugShareResponse( val redacted: Boolean? = null, val auto_delete_seconds: Long? = null, ) + +@Serializable +data class DebugShareRequest( + val redact: Boolean? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/HookResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/HookResponse.kt index 5f4f4b31..46ed5b8c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/HookResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/HookResponse.kt @@ -17,3 +17,19 @@ data class HookEntry( val approved_at: String? = null, val executable: Boolean? = null, ) + +/** Body for POST api/ops/hooks. The backend field is `approve` (writes the consent allowlist entry). */ +@Serializable +data class HookCreateRequest( + val event: String, + val command: String, + val matcher: String? = null, + val timeout: Int? = null, + val approve: Boolean? = null, +) + +@Serializable +data class HookDeleteRequest( + val event: String, + val command: String, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt index 9b671430..2e856c48 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt @@ -96,3 +96,9 @@ data class McpOAuthFlowResponse( val authorizationUrl: String? = null, val error: String? = null, ) + +/** Body for PUT api/mcp/servers/{name}; only the fields being changed are sent. */ +@Serializable +data class McpServerUpdateRequest( + val env: Map? = null, +) 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 a2a486c6..3e15cf62 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 @@ -20,10 +20,12 @@ import com.m57.hermescontrol.data.model.CreateCronJobRequest import com.m57.hermescontrol.data.model.CreateProfileRequest import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateWebhookRequest +import com.m57.hermescontrol.data.model.CredentialPoolAddRequest import com.m57.hermescontrol.data.model.CredentialPoolResponse import com.m57.hermescontrol.data.model.CronBlueprintListResponse import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.data.model.CuratorResponse +import com.m57.hermescontrol.data.model.DebugShareRequest import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DeleteWebhookResponse import com.m57.hermescontrol.data.model.DeliveryTargetsResponse @@ -38,6 +40,8 @@ import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.model.GatewayMigrationPlan import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse import com.m57.hermescontrol.data.model.HealthStatus +import com.m57.hermescontrol.data.model.HookCreateRequest +import com.m57.hermescontrol.data.model.HookDeleteRequest import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest import com.m57.hermescontrol.data.model.KanbanBoardResponse @@ -57,6 +61,7 @@ import com.m57.hermescontrol.data.model.McpOAuthFlowResponse import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServerTestResponse import com.m57.hermescontrol.data.model.McpServerToggleRequest +import com.m57.hermescontrol.data.model.McpServerUpdateRequest import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateRequest @@ -583,7 +588,7 @@ interface HermesApiService : KanbanApiService { @PUT("api/mcp/servers/{name}") suspend fun updateMcpServer( @Path("name") name: String, - @Body body: Map, + @Body body: McpServerUpdateRequest, ): Response @POST("api/mcp/servers/{name}/auth") @@ -602,7 +607,7 @@ interface HermesApiService : KanbanApiService { @POST("api/mcp/catalog/install") suspend fun installMcpCatalogEntry( @Body body: McpCatalogInstallRequest, - ): Response> + ): Response @GET("api/webhooks") suspend fun getWebhooks(): Response @@ -881,7 +886,7 @@ interface HermesApiService : KanbanApiService { @PUT("api/curator/paused") suspend fun setCuratorPaused( @Body body: Map, - ): Response> + ): Response @POST("api/curator/run") suspend fun runCurator(): Response @@ -918,14 +923,14 @@ interface HermesApiService : KanbanApiService { @POST("api/credentials/pool") suspend fun addCredentialPoolEntry( - @Body body: Map, - ): Response> + @Body body: CredentialPoolAddRequest, + ): Response @DELETE("api/credentials/pool/{provider}/{index}") suspend fun removeCredentialPoolEntry( @Path("provider") provider: String, @Path("index") index: Int, - ): Response> + ): Response // ── Admin: Operations ───────────────────────────────────────────── @POST("api/ops/security-audit") @@ -968,7 +973,7 @@ interface HermesApiService : KanbanApiService { // ── Admin: Debug share ──────────────────────────────────────────── @POST("api/ops/debug-share") suspend fun runDebugShare( - @Body body: Map, + @Body body: DebugShareRequest, ): Response // ── Admin: Checkpoints ──────────────────────────────────────────── @@ -984,13 +989,13 @@ interface HermesApiService : KanbanApiService { @POST("api/ops/hooks") suspend fun createHook( - @Body body: Map, - ): Response> + @Body body: HookCreateRequest, + ): Response - @DELETE("api/ops/hooks") + @HTTP(method = "DELETE", path = "api/ops/hooks", hasBody = true) suspend fun deleteHook( - @Body body: Map, - ): Response> + @Body body: HookDeleteRequest, + ): Response // ── Admin: Action status (log viewer) ──────────────────────────── @GET("api/actions/{name}/status") diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index 97ac71bf..a83e0421 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.model.McpOAuthFlowResponse import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServerTestResponse import com.m57.hermescontrol.data.model.McpServerToggleRequest +import com.m57.hermescontrol.data.model.McpServerUpdateRequest import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkError @@ -458,7 +459,12 @@ class McpServersViewModel( val updatedEnv = existingEnv + (key to value) val result = withContext(ioDispatcher) { - safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) } + safeApiCall { + ApiClient.hermesApi.updateMcpServer( + serverName, + McpServerUpdateRequest(env = updatedEnv), + ) + } } when (result) { is NetworkResult.Success -> { @@ -490,7 +496,12 @@ class McpServersViewModel( val updatedEnv = existingEnv - key val result = withContext(ioDispatcher) { - safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) } + safeApiCall { + ApiClient.hermesApi.updateMcpServer( + serverName, + McpServerUpdateRequest(env = updatedEnv), + ) + } } when (result) { is NetworkResult.Success -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index 88b8b69e..2de21c08 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -11,11 +11,15 @@ import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.model.ActionStatusResponse import com.m57.hermescontrol.data.model.BackupTriggerRequest import com.m57.hermescontrol.data.model.CheckpointsResponse +import com.m57.hermescontrol.data.model.CredentialPoolAddRequest import com.m57.hermescontrol.data.model.CredentialPoolProvider import com.m57.hermescontrol.data.model.CuratorResponse +import com.m57.hermescontrol.data.model.DebugShareRequest import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DoctorResponse import com.m57.hermescontrol.data.model.GatewayMigrationPlan +import com.m57.hermescontrol.data.model.HookCreateRequest +import com.m57.hermescontrol.data.model.HookDeleteRequest import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.PortalResponse import com.m57.hermescontrol.data.model.StatusResponse @@ -531,11 +535,11 @@ class SystemViewModel( _uiState.update { it.copy(addingCred = true) } viewModelScope.launch { val body = - buildMap { - put("provider", state.credProvider) - put("token", state.credKey) - if (state.credLabel.isNotBlank()) put("label", state.credLabel) - } + CredentialPoolAddRequest( + provider = state.credProvider, + apiKey = state.credKey, + label = state.credLabel.ifBlank { null }, + ) val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.addCredentialPoolEntry(body) } @@ -780,7 +784,7 @@ class SystemViewModel( viewModelScope.launch { val result = withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.runDebugShare(mapOf("redact" to shareRedact)) } + safeApiCall { ApiClient.hermesApi.runDebugShare(DebugShareRequest(redact = shareRedact)) } } when (result) { is NetworkResult.Success -> { @@ -849,13 +853,13 @@ class SystemViewModel( _uiState.update { it.copy(creatingHook = true) } viewModelScope.launch { val body = - buildMap { - put("event", state.hookEvent) - put("command", state.hookCommand) - if (state.hookMatcher.isNotBlank()) put("matcher", state.hookMatcher) - if (state.hookTimeout.isNotBlank()) put("timeout", state.hookTimeout.toIntOrNull() ?: 30) - put("allowed", state.hookApprove) - } + HookCreateRequest( + event = state.hookEvent, + command = state.hookCommand, + matcher = state.hookMatcher.ifBlank { null }, + timeout = if (state.hookTimeout.isBlank()) null else state.hookTimeout.toIntOrNull() ?: 30, + approve = state.hookApprove, + ) val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.createHook(body) } @@ -894,7 +898,7 @@ class SystemViewModel( viewModelScope.launch { val result = withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.deleteHook(mapOf("event" to event, "command" to command)) } + safeApiCall { ApiClient.hermesApi.deleteHook(HookDeleteRequest(event, command)) } } when (result) { is NetworkResult.Success -> { diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceConverterTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceConverterTest.kt new file mode 100644 index 00000000..0cb63108 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceConverterTest.kt @@ -0,0 +1,114 @@ +package com.m57.hermescontrol.data.remote + +import com.m57.hermescontrol.data.model.CredentialPoolAddRequest +import com.m57.hermescontrol.data.model.DebugShareRequest +import com.m57.hermescontrol.data.model.HookCreateRequest +import com.m57.hermescontrol.data.model.HookDeleteRequest +import com.m57.hermescontrol.data.model.McpCatalogInstallRequest +import com.m57.hermescontrol.data.model.McpServerUpdateRequest +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory + +/** + * Regression for #1402: `Map` bodies/results have no kotlinx serializer, so Retrofit failed to + * create the converter before any request was sent. These call the real service methods so converter creation + * (which JSON round-trip tests never exercise) is covered. + */ +class HermesApiServiceConverterTest { + private lateinit var server: MockWebServer + private lateinit var api: HermesApiService + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + api = + Retrofit + .Builder() + .baseUrl(server.url("/")) + .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType())) + .build() + .create(HermesApiService::class.java) + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun ok(body: String = """{"ok":true}""") = + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(body)) + + @Test + fun catalogInstall_readsResponse() = + runBlocking { + ok("""{"ok":true,"name":"example","background":false}""") + val r = api.installMcpCatalogEntry(McpCatalogInstallRequest(name = "example")) + assertTrue(r.isSuccessful) + assertEquals("example", r.body()?.name) + assertEquals("/api/mcp/catalog/install", server.takeRequest().path) + } + + @Test + fun updateMcpServer_sendsEnvBody() = + runBlocking { + ok("""{"name":"s","enabled":true}""") + api.updateMcpServer("s", McpServerUpdateRequest(env = mapOf("K" to "v"))) + val req = server.takeRequest() + assertEquals("PUT", req.method) + assertEquals("""{"env":{"K":"v"}}""", req.body.readUtf8()) + } + + @Test + fun curatorPaused_readsResponse() = + runBlocking { + ok("""{"ok":true,"paused":true}""") + assertTrue(api.setCuratorPaused(mapOf("paused" to true)).isSuccessful) + assertEquals("""{"paused":true}""", server.takeRequest().body.readUtf8()) + } + + @Test + fun credentialPool_addSendsApiKeyField_removeReadsResponse() = + runBlocking { + ok("""{"ok":true,"provider":"openrouter","count":1}""") + assertTrue(api.addCredentialPoolEntry(CredentialPoolAddRequest("openrouter", "sk", "lbl")).isSuccessful) + assertEquals( + """{"provider":"openrouter","api_key":"sk","label":"lbl"}""", + server.takeRequest().body.readUtf8(), + ) + ok("""{"ok":true,"provider":"openrouter","count":0,"cleaned":[],"hints":[]}""") + assertTrue(api.removeCredentialPoolEntry("openrouter", 1).isSuccessful) + } + + @Test + fun hooks_createSendsApprove_deleteSendsBody() = + runBlocking { + ok("""{"ok":true,"event":"e","command":"c","approved":true}""") + assertTrue(api.createHook(HookCreateRequest("e", "c", timeout = 5, approve = true)).isSuccessful) + assertEquals( + """{"event":"e","command":"c","timeout":5,"approve":true}""", + server.takeRequest().body.readUtf8(), + ) + ok() + assertTrue(api.deleteHook(HookDeleteRequest("e", "c")).isSuccessful) + val del = server.takeRequest() + assertEquals("DELETE", del.method) + assertEquals("""{"event":"e","command":"c"}""", del.body.readUtf8()) + } + + @Test + fun debugShare_sendsRedact() = + runBlocking { + ok("""{"ok":true,"urls":{"report":"u"}}""") + assertTrue(api.runDebugShare(DebugShareRequest(redact = false)).isSuccessful) + assertEquals("""{"redact":false}""", server.takeRequest().body.readUtf8()) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt index 81c7c7a1..383898b8 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.mcp import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServerTestResponse +import com.m57.hermescontrol.data.model.McpServerUpdateRequest import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService @@ -122,10 +123,10 @@ class McpOwnershipTest { vm.updateEnvValue("value") vm.addEnvVar(config.name) dispatcher.scheduler.advanceUntilIdle() - coVerify { api.updateMcpServer(config.name, mapOf("env" to mapOf("KEY" to "value"))) } + coVerify { api.updateMcpServer(config.name, McpServerUpdateRequest(env = mapOf("KEY" to "value"))) } vm.removeEnvVar(config.name, "KEY") dispatcher.scheduler.advanceUntilIdle() - coVerify { api.updateMcpServer(config.name, mapOf("env" to emptyMap())) } + coVerify { api.updateMcpServer(config.name, McpServerUpdateRequest(env = emptyMap())) } coEvery { api.deleteMcpServer(config.name) } returns Response.success(Unit) vm.deleteServer(config.name) dispatcher.scheduler.advanceUntilIdle() From 81b5e6d84bc9d85daeb5bd1d2a2aab97ebb4d5f8 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:15:30 +0300 Subject: [PATCH 086/107] feat(mcp): Installed/Catalog tabs with search, matching Plugins and Skills (#1404) --- .../hermescontrol/ui/mcp/McpServersScreen.kt | 291 +++++++++--------- .../ui/mcp/McpServersViewModel.kt | 11 + .../ui/mcp/components/AddServerSection.kt | 40 --- .../ui/mcp/components/CatalogSection.kt | 119 +++---- .../ui/mcp/components/McpSectionHeader.kt | 59 ---- app/src/main/res/values/strings.xml | 3 + 6 files changed, 204 insertions(+), 319 deletions(-) delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt index cc79bb43..ede67f7c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -1,98 +1,59 @@ package com.m57.hermescontrol.ui.mcp -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Key -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Science -import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.UploadFile -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilterChip -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SecondaryTabRow -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Tab +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R import com.m57.hermescontrol.data.local.AuthManager -import com.m57.hermescontrol.data.model.McpCatalogEntry import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.theme.LocalSpacing -import com.m57.hermescontrol.ui.common.DetailDialog import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SkeletonListState -import com.m57.hermescontrol.ui.common.StatusBadge -import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing -import com.m57.hermescontrol.ui.common.toDetailRows import com.m57.hermescontrol.ui.mcp.components.AddServerSection -import com.m57.hermescontrol.ui.mcp.components.CatalogSection +import com.m57.hermescontrol.ui.mcp.components.CatalogView import com.m57.hermescontrol.ui.mcp.components.McpDialogs import com.m57.hermescontrol.ui.mcp.components.ServerCard +@OptIn(ExperimentalMaterial3Api::class) @Composable fun McpServersScreen( modifier: Modifier = Modifier, @@ -138,120 +99,158 @@ fun McpServersScreen( HermesScaffold( title = { Text(stringResource(R.string.screen_mcp_servers)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, - isRefreshing = state.isLoading, - onRefresh = { viewModel.loadServers(forceRefresh = true) }, - actions = { - IconButton( - onClick = { viewModel.testAllServers() }, - enabled = !state.isTestingAll && state.servers.any { it.enabled }, - ) { - if (state.isTestingAll) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - ) - } else { - Icon( - imageVector = Icons.Filled.Science, - contentDescription = stringResource(R.string.mcp_servers_action_test_all), - ) - } - } - IconButton( - onClick = { viewModel.toggleImportDialog() }, - ) { - Icon( - imageVector = Icons.Filled.UploadFile, - contentDescription = stringResource(R.string.mcp_servers_action_import_json), - ) + isRefreshing = if (state.selectedTab == McpTab.INSTALLED) state.isLoading else state.catalogLoading, + onRefresh = { + if (state.selectedTab == McpTab.INSTALLED) { + viewModel.loadServers(forceRefresh = true) + } else { + viewModel.loadCatalog() } }, - ) { paddingValues -> - when { - state.isLoading && state.servers.isEmpty() -> { - SkeletonListState(modifier = Modifier.padding(paddingValues)) - } - - state.errorMessage != null -> { - ErrorState( - message = state.errorMessage ?: "", - onRetry = { viewModel.loadServers() }, - modifier = Modifier.padding(paddingValues), - ) - } - - else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listContentPadding, - verticalArrangement = listItemSpacing, + actions = { + if (state.selectedTab == McpTab.INSTALLED) { + IconButton(onClick = { viewModel.toggleAddForm() }) { + Icon( + imageVector = if (state.showAddForm) Icons.Filled.Close else Icons.Filled.Add, + contentDescription = stringResource(R.string.mcp_servers_add_server), + ) + } + IconButton(onClick = { viewModel.toggleImportDialog() }) { + Icon( + imageVector = Icons.Filled.UploadFile, + contentDescription = stringResource(R.string.mcp_servers_action_import_json), + ) + } + IconButton( + onClick = { viewModel.testAllServers() }, + enabled = !state.isTestingAll && state.servers.any { it.enabled }, ) { - // ── 1. Add server ──────────────────────────────────── - item(key = "add-header") { - AddServerSection(state, viewModel, spacing) - } - - // ── 2. Search ──────────────────────────────────────── - item(key = "search") { - SearchBar( - query = query, - onQueryChange = { query = it }, - placeholder = stringResource(R.string.mcp_search_placeholder), + if (state.isTestingAll) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.Filled.Science, + contentDescription = stringResource(R.string.mcp_servers_action_test_all), ) } + } + } + }, + ) { _ -> + Column(modifier = Modifier.fillMaxSize()) { + SingleChoiceSegmentedButtonRow( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + SegmentedButton( + selected = state.selectedTab == McpTab.INSTALLED, + onClick = { viewModel.setTab(McpTab.INSTALLED) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + ) { + Text(stringResource(R.string.mcp_tab_installed)) + } + SegmentedButton( + selected = state.selectedTab == McpTab.CATALOG, + onClick = { viewModel.setTab(McpTab.CATALOG) }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + ) { + Text(stringResource(R.string.mcp_tab_catalog)) + } + } - // ── 3. Server list ─────────────────────────────────── - if (filteredServers.isEmpty() && query.isEmpty()) { - item(key = "empty") { - EmptyState( - title = stringResource(R.string.mcp_servers_empty_title), - subtitle = stringResource(R.string.mcp_servers_empty_desc), - actionLabel = stringResource(R.string.empty_action_add_server), - onAction = { viewModel.toggleAddForm() }, + when (state.selectedTab) { + McpTab.INSTALLED -> { + when { + state.isLoading && state.servers.isEmpty() -> { + SkeletonListState() + } + + state.errorMessage != null && state.servers.isEmpty() -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadServers() }, ) } - } else if (filteredServers.isEmpty()) { - item(key = "no-match") { - Text( - text = stringResource(R.string.mcp_no_match, query), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(spacing.md), - ) - } - } - items(filteredServers, key = { "server:${it.name}" }) { server -> - ServerCard( - server = server, - state = state, - viewModel = viewModel, - spacing = spacing, - onClick = { showDetail = server }, - onOpenBrowser = { url -> - try { - context.startActivity( - android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse(url), - ), - ) - } catch (_: Exception) { + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + item(key = "add-form") { + AddServerSection(state, viewModel, spacing) } - }, - ) - } - // ── 4. Catalog ────────────────────────────────────── - item(key = "catalog-header") { - CatalogSection( - state = state, - viewModel = viewModel, - spacing = spacing, - filteredCatalog = filteredCatalog, - ) + if (state.servers.isNotEmpty()) { + item(key = "search") { + SearchBar( + query = query, + onQueryChange = { query = it }, + placeholder = stringResource(R.string.mcp_search_placeholder), + ) + } + } + + if (state.servers.isEmpty()) { + item(key = "empty") { + EmptyState( + title = stringResource(R.string.mcp_servers_empty_title), + subtitle = stringResource(R.string.mcp_servers_empty_desc), + actionLabel = stringResource(R.string.mcp_tab_catalog), + onAction = { viewModel.setTab(McpTab.CATALOG) }, + modifier = Modifier.height(320.dp), + ) + } + } else if (filteredServers.isEmpty()) { + item(key = "no-match") { + Text( + text = stringResource(R.string.mcp_no_match, query), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(spacing.md), + ) + } + } + + items(filteredServers, key = { "server:${it.name}" }) { server -> + ServerCard( + server = server, + state = state, + viewModel = viewModel, + spacing = spacing, + onClick = { showDetail = server }, + onOpenBrowser = { url -> + try { + context.startActivity( + android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse(url), + ), + ) + } catch (_: Exception) { + } + }, + ) + } + } + } } } + + McpTab.CATALOG -> { + CatalogView( + state = state, + viewModel = viewModel, + spacing = spacing, + filteredCatalog = filteredCatalog, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index a83e0421..8232801b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -32,7 +32,10 @@ import kotlinx.coroutines.withContext enum class AddServerMode { HTTP, Stdio } +enum class McpTab { INSTALLED, CATALOG } + data class McpServersUiState( + val selectedTab: McpTab = McpTab.INSTALLED, val isLoading: Boolean = false, val servers: List = emptyList(), val errorMessage: String? = null, @@ -547,6 +550,14 @@ class McpServersViewModel( } } + fun setTab(tab: McpTab) { + _uiState.update { it.copy(selectedTab = tab) } + val s = _uiState.value + if (tab == McpTab.CATALOG && s.catalogEntries.isEmpty() && !s.catalogLoading) { + loadCatalog() + } + } + fun updateCatalogQuery(v: String) { _uiState.update { it.copy(catalogQuery = v) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt index 4bae7bf1..3bc37626 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt @@ -5,29 +5,20 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -50,37 +41,6 @@ fun AddServerSection( modifier: Modifier = Modifier, ) { Column(modifier = modifier) { - McpSectionHeader( - icon = Icons.Filled.Add, - title = stringResource(R.string.mcp_servers_add_server), - trailing = { - Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) { - TextButton( - onClick = { viewModel.toggleImportDialog() }, - colors = - ButtonDefaults.textButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_action_import_json)) - } - TextButton( - onClick = { viewModel.toggleAddForm() }, - colors = - ButtonDefaults.textButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ), - ) { - Text(if (state.showAddForm) "Hide" else "New") - } - } - }, - ) - AnimatedVisibility(visible = state.showAddForm) { Card( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt index 533123d2..2e2865d3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt @@ -2,25 +2,21 @@ package com.m57.hermescontrol.ui.mcp.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Storage +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -34,91 +30,66 @@ import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.McpCatalogEntry import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.SearchBar +import com.m57.hermescontrol.ui.common.SkeletonListState +import com.m57.hermescontrol.ui.common.listContentPadding +import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.mcp.McpServersUiState import com.m57.hermescontrol.ui.mcp.McpServersViewModel @Composable -fun CatalogSection( +fun CatalogView( state: McpServersUiState, viewModel: McpServersViewModel, spacing: Spacing, filteredCatalog: List, modifier: Modifier = Modifier, ) { - val catalogExpanded = remember { mutableStateOf(false) } - - Column(modifier = modifier) { - McpSectionHeader( - icon = Icons.Filled.Storage, - title = stringResource(R.string.mcp_servers_section_catalog), - trailing = { - TextButton( - onClick = { - catalogExpanded.value = !catalogExpanded.value - if (catalogExpanded.value && state.catalogEntries.isEmpty() && !state.catalogLoading) { - viewModel.loadCatalog() - } - }, - colors = - ButtonDefaults.textButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ), - ) { - Text(if (catalogExpanded.value) "Hide" else "Browse") - } - }, + Column(modifier = modifier.fillMaxSize()) { + SearchBar( + query = state.catalogQuery, + onQueryChange = viewModel::updateCatalogQuery, + placeholder = stringResource(R.string.mcp_servers_catalog_search), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), ) - AnimatedVisibility(visible = catalogExpanded.value) { - Column(modifier = Modifier.fillMaxWidth()) { - // Search - OutlinedTextField( - value = state.catalogQuery, - onValueChange = viewModel::updateCatalogQuery, - label = { Text(stringResource(R.string.mcp_servers_catalog_search)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(bottom = spacing.sm), + when { + state.catalogLoading && state.catalogEntries.isEmpty() -> { + SkeletonListState() + } + + state.catalogError != null && state.catalogEntries.isEmpty() -> { + ErrorState( + message = state.catalogError, + onRetry = { viewModel.loadCatalog() }, ) + } - // Loading / error / content - when { - state.catalogLoading -> { - Spacer(modifier = Modifier.height(spacing.sm)) - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - Spacer(modifier = Modifier.height(spacing.sm)) - } + filteredCatalog.isEmpty() -> { + EmptyState( + title = stringResource(R.string.mcp_servers_catalog_empty_title), + subtitle = stringResource(R.string.mcp_servers_catalog_empty), + actionLabel = stringResource(R.string.content_desc_refresh), + onAction = { viewModel.loadCatalog() }, + ) + } - state.catalogError != null -> { - ErrorState( - message = state.catalogError, - onRetry = { viewModel.loadCatalog() }, + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + items(filteredCatalog, key = { "catalog:${it.name}" }) { entry -> + CatalogEntryCard( + entry = entry, + state = state, + viewModel = viewModel, + spacing = spacing, ) } - - filteredCatalog.isEmpty() -> { - Text( - text = stringResource(R.string.mcp_servers_catalog_empty), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = spacing.md), - ) - } - - else -> { - filteredCatalog.forEach { entry -> - CatalogEntryCard( - entry = entry, - state = state, - viewModel = viewModel, - spacing = spacing, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - } - } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt deleted file mode 100644 index 9dd01b89..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.m57.hermescontrol.ui.mcp.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.m57.hermescontrol.theme.LocalSpacing - -@Composable -fun McpSectionHeader( - icon: ImageVector, - title: String, - modifier: Modifier = Modifier, - trailing: (@Composable () -> Unit)? = null, -) { - val spacing = LocalSpacing.current - Row( - modifier = - modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = RoundedCornerShape(12.dp), - ).padding(horizontal = spacing.md, vertical = spacing.sm), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(spacing.sm)) - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - ) - } - trailing?.invoke() - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a057be79..b4023d78 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1840,4 +1840,7 @@ Reconnect No tools available Read-only policy restrictions + Installed + Catalog + No catalog entries From 6810ef02feabbcd1a58ecc0b3b3f13ba90c5e84c Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:15:30 +0300 Subject: [PATCH 087/107] fix(mcp): show Installed state for catalog entries already installed (#1405) --- .../m57/hermescontrol/data/model/McpServer.kt | 22 ++++++-- .../ui/mcp/McpServersViewModel.kt | 1 + .../ui/mcp/components/CatalogSection.kt | 51 ++++++++++++------- .../ui/mcp/components/ServerCard.kt | 2 +- app/src/main/res/values/strings.xml | 1 + .../data/model/McpServerParsingTest.kt | 37 ++++++++++++++ 6 files changed, 92 insertions(+), 22 deletions(-) create mode 100644 app/src/test/java/com/m57/hermescontrol/data/model/McpServerParsingTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt index 2e856c48..3ce29ce3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt @@ -1,6 +1,9 @@ package com.m57.hermescontrol.data.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject @Serializable data class McpServersResponse( @@ -19,12 +22,21 @@ data class McpServer( val status: String? = null, val error: String? = null, val auth: String? = null, - val tools: List? = null, + val tools: JsonElement? = null, val source: String = "config", val plugin: String? = null, ) { // #1283: plugin-provided servers are managed by their owning plugin. val isPluginOwned: Boolean get() = source == "plugin" + + // Backend sends `tools` as null, a name list, or a `{include, exclude}` filter object. + val toolCount: Int? + get() = + when (val t = tools) { + is JsonArray -> t.size + is JsonObject -> (t["include"] as? JsonArray)?.size + else -> null + } } @Serializable @@ -71,13 +83,15 @@ data class McpCatalogEntry( val url: String? = null, val command: String? = null, val args: List? = null, - val env: List? = null, + @SerialName("required_env") val env: List? = null, + val installed: Boolean = false, + val enabled: Boolean = false, ) @Serializable data class McpCatalogEnvVar( - val key: String, - val label: String? = null, + @SerialName("name") val key: String, + @SerialName("prompt") val label: String? = null, val description: String? = null, val required: Boolean = false, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index 8232801b..fbbb910e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -585,6 +585,7 @@ class McpServersViewModel( ) } loadServers(forceRefresh = true) + loadCatalog() } is NetworkResult.Failure -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt index 2e2865d3..5aaf7abf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt @@ -9,11 +9,17 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -47,6 +53,7 @@ fun CatalogView( filteredCatalog: List, modifier: Modifier = Modifier, ) { + val installedNames = remember(state.servers) { state.servers.map { it.name }.toSet() } Column(modifier = modifier.fillMaxSize()) { SearchBar( query = state.catalogQuery, @@ -88,6 +95,7 @@ fun CatalogView( state = state, viewModel = viewModel, spacing = spacing, + isInstalled = entry.installed || entry.name in installedNames, ) } } @@ -103,6 +111,7 @@ fun CatalogEntryCard( viewModel: McpServersViewModel, spacing: Spacing, modifier: Modifier = Modifier, + isInstalled: Boolean = false, ) { var showInstallForm by remember { mutableStateOf(false) } val isInstalling = state.installingCatalogEntry == entry.name @@ -131,23 +140,31 @@ fun CatalogEntryCard( ) } } - Button( - onClick = { - if (entry.env?.isNotEmpty() == true) { - showInstallForm = !showInstallForm - } else { - viewModel.installCatalogEntry(entry) - } - }, - enabled = !isInstalling, - ) { - Text( - if (isInstalling) { - stringResource(R.string.mcp_servers_catalog_installing) - } else { - stringResource(R.string.mcp_servers_catalog_install) + if (isInstalled) { + FilledTonalButton(onClick = {}, enabled = false) { + Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_catalog_installed)) + } + } else { + Button( + onClick = { + if (entry.env?.isNotEmpty() == true) { + showInstallForm = !showInstallForm + } else { + viewModel.installCatalogEntry(entry) + } }, - ) + enabled = !isInstalling, + ) { + Text( + if (isInstalling) { + stringResource(R.string.mcp_servers_catalog_installing) + } else { + stringResource(R.string.mcp_servers_catalog_install) + }, + ) + } } } @@ -161,7 +178,7 @@ fun CatalogEntryCard( } // Install form with env vars - AnimatedVisibility(visible = showInstallForm) { + AnimatedVisibility(visible = showInstallForm && !isInstalled) { Column(modifier = Modifier.padding(top = spacing.sm)) { entry.env?.let { envVars -> if (envVars.isNotEmpty()) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt index b5b22347..da6ca944 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt @@ -151,7 +151,7 @@ fun ServerCard( // Tool count & schema token overhead badge (issue #1029) val toolInfos = testResult?.tools - val toolCount = toolInfos?.size ?: server.tools?.size + val toolCount = toolInfos?.size ?: server.toolCount if (toolCount != null && toolCount > 0) { val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) } val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4023d78..87ac0e5d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1843,4 +1843,5 @@ Installed Catalog No catalog entries + Installed diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/McpServerParsingTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/McpServerParsingTest.kt new file mode 100644 index 00000000..0d633ca4 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/McpServerParsingTest.kt @@ -0,0 +1,37 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class McpServerParsingTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `servers decode tools as null, list, or filter object`() { + val body = + """{"servers":[ + {"name":"a","enabled":true,"tools":null}, + {"name":"b","enabled":true,"tools":["x","y"]}, + {"name":"c","enabled":true,"tools":{"exclude":["docs","*_radar*"]}}, + {"name":"d","enabled":true,"tools":{"include":["one","two","three"]}} + ]}""" + val servers = json.decodeFromString(body).servers + assertEquals(listOf(null, 2, null, 3), servers.map { it.toolCount }) + } + + @Test + fun `catalog entry decodes backend required_env and installed flags`() { + val body = + """{"entries":[{"name":"gh","description":"d","source":"nous","transport":"stdio", + "required_env":[{"name":"GH_TOKEN","prompt":"Token","required":true}], + "installed":true,"enabled":false,"needs_install":false}],"diagnostics":[]}""" + val entry = json.decodeFromString(body).entries.single() + assertTrue(entry.installed) + assertEquals("GH_TOKEN", entry.env?.single()?.key) + assertEquals("Token", entry.env?.single()?.label) + assertNull(entry.url) + } +} From 48de25985aa181781d4595045add4bd947932d0f Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:30:41 +0300 Subject: [PATCH 088/107] fix(providers): accept numeric expires_at in OAuth status (#1407) --- .../hermescontrol/data/model/OAuthProvider.kt | 43 +++++++++++++++++-- .../HermesApiServiceMockWebServerTest.kt | 21 +++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt b/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt index f5525b2e..3315dd19 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt @@ -1,7 +1,17 @@ package com.m57.hermescontrol.data.model +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive /** * OAuth provider catalog entry returned by `GET /api/providers/oauth`. @@ -10,7 +20,8 @@ import kotlinx.serialization.Serializable * id, name, flow ("pkce" | "device_code" | "external"), cli_command, * docs_url, disconnect_hint, disconnect_command, disconnectable, status. * - * `expires_at` in the status comes back as an ISO timestamp string. + * `expires_at` / `last_refresh` come back as an ISO string or a numeric epoch depending on the + * credential source, so they decode through [LenientStringSerializer]. */ @Serializable data class OAuthProvider( @@ -31,9 +42,13 @@ data class OAuthProviderStatus( val source: String? = null, @SerialName("source_label") val sourceLabel: String? = null, @SerialName("token_preview") val tokenPreview: String? = null, - @SerialName("expires_at") val expiresAt: String? = null, + @SerialName("expires_at") + @Serializable(with = LenientStringSerializer::class) + val expiresAt: String? = null, @SerialName("has_refresh_token") val hasRefreshToken: Boolean? = null, - @SerialName("last_refresh") val lastRefresh: String? = null, + @SerialName("last_refresh") + @Serializable(with = LenientStringSerializer::class) + val lastRefresh: String? = null, val error: String? = null, ) @@ -98,3 +113,25 @@ data class OAuthCancelResponse( @SerialName("session_id") val sessionId: String? = null, val message: String? = null, ) + +/** Decodes a JSON string or number (e.g. epoch millis) into its string form; null stays null. */ +@OptIn(ExperimentalSerializationApi::class) +object LenientStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("LenientString", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): String? { + if (decoder is JsonDecoder) { + val element = decoder.decodeJsonElement() + return if (element is JsonPrimitive && element !is JsonNull) element.content else null + } + return decoder.decodeString() + } + + override fun serialize( + encoder: Encoder, + value: String?, + ) { + if (value == null) encoder.encodeNull() else encoder.encodeString(value) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt index a221a389..8e7aaacf 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt @@ -598,6 +598,27 @@ class HermesApiServiceMockWebServerTest { assertEquals(404, response.code()) } + @Test + fun getOAuthProviders_toleratesNumericExpiresAt() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + {"providers":[{"id":"codex","name":"Codex","flow":"external", + "status":{"logged_in":true,"expires_at":1790766533634,"last_refresh":1790766000000}}]} + """.trimIndent(), + ), + ) + + val response = api.getOAuthProviders() + assertTrue(response.isSuccessful) + val status = response.body()!!.providers[0].status + assertEquals("1790766533634", status.expiresAt) + assertEquals("1790766000000", status.lastRefresh) + } + @Test fun getOAuthProviders_parsesList() = runBlocking { From 05519ba1ffe31615feb260e140ec3a527af456e2 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:30:55 +0300 Subject: [PATCH 089/107] feat(notification): Turn off action and tap-to-open on background notification (#1408) --- app/src/main/AndroidManifest.xml | 4 ++ .../BackgroundConnectionController.kt | 11 ++++ .../notification/ChatNotificationService.kt | 52 +++++++++++++++---- .../notification/StopKeepConnectedReceiver.kt | 26 ++++++++++ app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../BackgroundConnectionControllerTest.kt | 51 ++++++++++++++++++ 9 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/notification/StopKeepConnectedReceiver.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8da078e3..6311e266 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -71,6 +71,10 @@ android:name=".notification.NotificationReplyReceiver" android:exported="false" /> + + = Build.VERSION_CODES.O) { diff --git a/app/src/main/java/com/m57/hermescontrol/notification/StopKeepConnectedReceiver.kt b/app/src/main/java/com/m57/hermescontrol/notification/StopKeepConnectedReceiver.kt new file mode 100644 index 00000000..e1d0e787 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/notification/StopKeepConnectedReceiver.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.m57.hermescontrol.data.local.AuthManager + +/** + * Handles the "Turn off" action on the ongoing background notification: + * disables "Stay connected in background" and lets the controller retire the + * lease/service. + */ +class StopKeepConnectedReceiver : BroadcastReceiver() { + companion object { + const val ACTION_STOP_KEEP_CONNECTED = "com.m57.hermescontrol.ACTION_STOP_KEEP_CONNECTED" + } + + override fun onReceive( + context: Context, + intent: Intent, + ) { + if (intent.action != ACTION_STOP_KEEP_CONNECTED) return + AuthManager.setKeepConnectedInBackground(false) + BackgroundConnectionController.default.onKeepConnectedDisabled() + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 4246ab8e..6a328570 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -129,6 +129,7 @@ Hermes بانتظار ردود Hermes متصل في الخلفية +إيقاف جارٍ الاتصال… في انتظار الشبكة جارٍ إعادة الاتصال diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 4a3e9465..fabaaf82 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -126,6 +126,7 @@ 헤르메스 헤르메스 응답 대기 중 백그라운드에서 연결됨 + 끄기 연결 중… 네트워크 대기 중 재연결 중 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 032fd94e..67868a7b 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -129,6 +129,7 @@ Hermes 等待 Hermes 回复 在后台保持连接 + 关闭 正在连接… 等待网络连接 正在重新连接 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 87ac0e5d..00ed6cd8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -152,6 +152,7 @@ Hermes Waiting for Hermes replies Connected in background + Turn off Connecting… Waiting for network Reconnecting diff --git a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt index 2a641154..209cb199 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt @@ -407,4 +407,55 @@ class BackgroundConnectionControllerTest { unmockkObject(NetworkMonitor) } } + + @Test + fun testOnKeepConnectedDisabled_whenBackgrounded_releasesLeaseAndStopsService() { + var leaseReleased = false + var serviceStopped = false + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = false, + keepConnectedOptIn = false, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + ) + val controller = + BackgroundConnectionController( + snapshotProvider = { snapshot }, + releaseLease = { leaseReleased = true }, + requestServiceStop = { serviceStopped = true }, + ) + + controller.onKeepConnectedDisabled() + + assertTrue(leaseReleased) + assertTrue(serviceStopped) + } + + @Test + fun testOnKeepConnectedDisabled_whenReplyPending_keepsServiceAndRefreshesNotification() { + var serviceStopped = false + var refreshed: BackgroundNotificationState? = null + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = false, + keepConnectedOptIn = false, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + ) + val controller = + BackgroundConnectionController( + snapshotProvider = { snapshot }, + releaseLease = {}, + requestServiceStop = { serviceStopped = true }, + onNotificationStateChanged = { refreshed = it }, + ) + + controller.onKeepConnectedDisabled() + + assertFalse(serviceStopped) + assertEquals(BackgroundNotificationState.WaitingForReplies, refreshed) + } } From 0e39a71591daf474ab1df28809b021069d6fbabc Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:33:07 +0300 Subject: [PATCH 090/107] feat(chat): /stop also kills background processes like desktop (#1409) --- .../m57/hermescontrol/data/ws/WsMethods.kt | 3 + .../data/ws/contract/ProcessStop.kt | 15 +++++ .../data/ws/contract/RpcMethod.kt | 8 +++ .../hermescontrol/ui/chat/ChatViewModel.kt | 27 ++++++++ .../ui/chat/SlashCommandDispatcher.kt | 9 ++- .../data/ws/contract/ProcessTypesTest.kt | 10 +++ .../ui/chat/ChatViewModelTest.kt | 61 ++++++++++++++++++- .../ui/chat/SlashCommandDispatcherTest.kt | 4 +- 8 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessStop.kt 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 f2e07a5a..0957f86a 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 @@ -94,6 +94,9 @@ object WsMethods { /** Kill a single background process (scoped to the active session). */ const val PROCESS_KILL = "process.kill" + /** Kill every background process in the gateway registry (`/stop`, global). */ + const val PROCESS_STOP = "process.stop" + // ── Subagents (issue #1089) ─────────────────────────────────────────── /** List active subagents and delegations for a session. */ diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessStop.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessStop.kt new file mode 100644 index 00000000..6e0a2f8f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessStop.kt @@ -0,0 +1,15 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.Serializable + +/** + * `process.stop` kills every background process in the gateway registry (the desktop `/stop` second half). Desktop + * sends `{}`: the backend call is global, so no `session_id` or `profile` override is sent. + */ +@Serializable +data object ProcessStopParams + +@Serializable +data class ProcessStopResult( + val killed: Int? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index f611d435..be4bb3ba 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -363,6 +363,13 @@ object RpcMethods { JsonElement.serializer(), ) + val PROCESS_STOP: RpcMethod = + RpcMethod( + WsMethods.PROCESS_STOP, + ProcessStopParams.serializer(), + ProcessStopResult.serializer(), + ) + // Passthrough JsonElement results: callers keep their existing legacy decoders. val PLUGINS_MANAGE: RpcMethod = RpcMethod( @@ -514,6 +521,7 @@ object RpcMethods { SUBAGENT_TAIL, PROCESS_LIST, PROCESS_KILL, + PROCESS_STOP, PLUGINS_MANAGE, PROJECTS_LIST, PROFILES_LIST, 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 a0c31cbe..c2d46f96 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 @@ -43,6 +43,7 @@ import com.m57.hermescontrol.data.ws.contract.ConfigGetParams import com.m57.hermescontrol.data.ws.contract.ConfigSetParams import com.m57.hermescontrol.data.ws.contract.FileAttachParams import com.m57.hermescontrol.data.ws.contract.ImageAttachBytesParams +import com.m57.hermescontrol.data.ws.contract.ProcessStopParams import com.m57.hermescontrol.data.ws.contract.PromptBtwParams import com.m57.hermescontrol.data.ws.contract.RpcMethod import com.m57.hermescontrol.data.ws.contract.RpcMethods @@ -3716,6 +3717,10 @@ class ChatViewModel( interruptSession() } + is SlashResult.Stop -> { + stopSessionAndProcesses() + } + is SlashResult.NewSession -> { val currentTitle = _uiState.value.chatTitle if (currentTitle.equals("Bot Chat", ignoreCase = true)) { @@ -4344,6 +4349,28 @@ class ChatViewModel( } } + /** + * `/stop`, matching the desktop app: interrupt the active turn (same path as the composer Stop button), then + * kill every background process via `process.stop`. The button stays interrupt-only. + */ + private fun stopSessionAndProcesses() { + interruptSession() + viewModelScope.launch(ioDispatcher) { + val message = + try { + val killed = wsClient.call(RpcMethods.PROCESS_STOP, ProcessStopParams).killed ?: 0 + when { + killed > 0 -> "Stopped $killed background process${if (killed == 1) "" else "es"}." + else -> "No background processes to stop." + } + } catch (e: Exception) { + if (e is CancellationException) throw e + "Could not stop background processes: ${e.message ?: e.javaClass.simpleName}" + } + addSystemMessage(message) + } + } + /** * Send course-correction guidance to a live subagent child session (issue #1030). */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt index c1aba996..65221a8a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -27,7 +27,11 @@ class SlashCommandDispatcher { val cmd = parts[0].lowercase() return when (cmd) { - "/stop", "/interrupt" -> { + "/stop" -> { + SlashResult.Stop + } + + "/interrupt" -> { SlashResult.Interrupt } @@ -96,6 +100,9 @@ sealed class SlashResult { /** Interrupt the active session (client-side immediate). */ data object Interrupt : SlashResult() + /** Desktop `/stop`: interrupt the active turn, then kill background processes (`process.stop`). */ + data object Stop : SlashResult() + /** Create a new session (client-side immediate). */ data object NewSession : SlashResult() diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt index 02544cad..443aae32 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt @@ -24,8 +24,18 @@ class ProcessTypesTest { ) } + @Test + fun stopEncodesAnEmptyObjectAndDecodesKilledCount() { + val encoded = OkHttpProvider.json.encodeToJsonElement(ProcessStopParams.serializer(), ProcessStopParams) + assertEquals(buildJsonObject {}, encoded) + val result = OkHttpProvider.json.decodeFromString(ProcessStopResult.serializer(), """{"killed":3}""") + assertEquals(ProcessStopResult(killed = 3), result) + } + @Test fun descriptorsAreRegisteredWithContractNames() { + assertTrue(RpcMethods.PROCESS_STOP in RpcMethods.all) + assertEquals("process.stop", RpcMethods.PROCESS_STOP.name) assertTrue(RpcMethods.PROCESS_LIST in RpcMethods.all) assertTrue(RpcMethods.PROCESS_KILL in RpcMethods.all) assertEquals("process.list", RpcMethods.PROCESS_LIST.name) 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 2dbe0d6b..7a35896f 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 @@ -36,6 +36,8 @@ 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.data.ws.contract.ConfigSetParams +import com.m57.hermescontrol.data.ws.contract.ProcessStopParams +import com.m57.hermescontrol.data.ws.contract.ProcessStopResult import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.notification.TurnCorrelationTracker import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository @@ -283,6 +285,9 @@ class ChatViewModelTest { HermesWsClient.send(arg(0), arg(1)) {} CompletableDeferred(Unit) } + // /stop also fires process.stop; default to "nothing to kill" so unrelated /stop tests never hit the socket. + coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } returns + ProcessStopResult(killed = 0) mockkObject(ApiClient) mockkObject(HermesDatabase) @@ -966,6 +971,52 @@ class ChatViewModelTest { verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } } + @Test + fun testSlashCommand_stop_alsoKillsBackgroundProcesses() = + runTest { + val (viewModel, _) = createViewModelWithSession() + coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } returns + ProcessStopResult(killed = 2) + + viewModel.sendMessage("/stop") + advanceUntilIdle() + + verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + coVerify(exactly = 1) { HermesWsClient.call(RpcMethods.PROCESS_STOP, ProcessStopParams, any(), any()) } + val contents = + viewModel.uiState.value.messages + .map { it.content } + assertTrue(contents.contains("Stopped 2 background processes.")) + } + + @Test + fun testSlashCommand_interrupt_doesNotKillBackgroundProcesses() = + runTest { + val (viewModel, _) = createViewModelWithSession() + + viewModel.sendMessage("/interrupt") + advanceUntilIdle() + + verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + coVerify(exactly = 0) { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } + } + + @Test + fun testSlashCommand_stop_reportsProcessStopFailure() = + runTest { + val (viewModel, _) = createViewModelWithSession() + coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } throws + IllegalStateException("boom") + + viewModel.sendMessage("/stop") + advanceUntilIdle() + + assertTrue( + viewModel.uiState.value.messages + .any { it.content == "Could not stop background processes: boom" }, + ) + } + @Test fun testSlashCommand_stop_andInterruptedNoticeStayInPlaceAcrossSync() = runTest { @@ -1041,7 +1092,15 @@ class ChatViewModelTest { val contents = merged.map { it.content } assertEquals( - listOf("start task", "running...", "/stop", "Session interrupted", "next prompt", "next reply"), + listOf( + "start task", + "running...", + "/stop", + "No background processes to stop.", + "Session interrupted", + "next prompt", + "next reply", + ), contents, ) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt index 272ad6ea..da0f7f8c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt @@ -20,8 +20,8 @@ class SlashCommandDispatcherTest { private val dispatcher = SlashCommandDispatcher() @Test - fun `stop routes to Interrupt`() { - assertEquals(SlashResult.Interrupt, dispatcher.dispatch("/stop")) + fun `stop routes to Stop`() { + assertEquals(SlashResult.Stop, dispatcher.dispatch("/stop")) } @Test From 3fb99f86ac48714a553201ac4282c6615cdc4da2 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:33:08 +0300 Subject: [PATCH 091/107] fix(chat): tint composer send/stop/mic button with theme primary (#1410) --- .../java/com/m57/hermescontrol/theme/Color.kt | 3 ++ .../ui/chat/components/ComposerPalette.kt | 29 +++++++++++++++++-- .../ui/chat/ComposerPaletteTest.kt | 15 ++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt index 3cb10951..89c05360 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt @@ -91,3 +91,6 @@ fun parseProjectColor(raw: String?): Color? { if (h !in 0f..360f || s !in 0f..1f || l !in 0f..1f) return null return Color.hsl(h % 360f, s, l) } + +/** Pure white glyph for filled accent controls (composer action button) when it is legible on the preset primary. */ +val GlyphWhite = Color(0xFFFFFFFF) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt index 531ba3f5..a1f2752f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance +import com.m57.hermescontrol.theme.GlyphWhite /** * Resolved colors for the chat composer card and its controls. @@ -15,7 +17,8 @@ import androidx.compose.ui.graphics.compositeOver * (Nord's High and Highest are the same color), which made the flat controls * vanish into the card. A fixed onSurface tint keeps them visible in every * preset and under dynamic color. Text and icon contrast is gated per preset - * in ComposerPaletteTest. + * in ComposerPaletteTest. The send / stop / mic action button is the one + * themed control: it takes the preset's primary so it follows the theme tint. */ internal data class ComposerPalette( val card: Color, @@ -30,10 +33,30 @@ internal data class ComposerPalette( ) private const val CONTROL_TINT_ALPHA = 0.12f +private const val ACTION_MIN_CONTRAST = 3f + +private fun contrastRatio( + a: Color, + b: Color, +): Float { + val l1 = a.luminance() + val l2 = b.luminance() + return (maxOf(l1, l2) + 0.05f) / (minOf(l1, l2) + 0.05f) +} internal fun composerPalette(scheme: ColorScheme): ComposerPalette { val card = scheme.surfaceContainer val tint = scheme.onSurface.copy(alpha = CONTROL_TINT_ALPHA) + // Pastel primaries (Nord light) vanish on the card; keep the neutral fill there. + val tinted = contrastRatio(scheme.primary, card) >= ACTION_MIN_CONTRAST + val action = if (tinted) scheme.primary else scheme.onSurface + // White glyph wherever it clears 3:1 on the primary; pastel primaries keep their own dark onPrimary. + val onAction = + when { + !tinted -> scheme.surface + contrastRatio(GlyphWhite, scheme.primary) >= ACTION_MIN_CONTRAST -> GlyphWhite + else -> scheme.onPrimary + } return ComposerPalette( card = card, cardBorder = tint.compositeOver(scheme.background), @@ -42,8 +65,8 @@ internal fun composerPalette(scheme: ColorScheme): ComposerPalette { control = tint.compositeOver(card), onControl = scheme.onSurface, onControlVariant = scheme.onSurfaceVariant, - action = scheme.onSurface, - onAction = scheme.surface, + action = action, + onAction = onAction, ) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt index 93a76b07..6e9fefb0 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat.components import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance +import com.m57.hermescontrol.theme.GlyphWhite import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.theme.resolveColorScheme import org.junit.Assert.assertTrue @@ -66,6 +67,20 @@ class ComposerPaletteTest { } } + @Test + fun actionButtonFollowsThePresetPrimaryWhenItIsLegible() { + forEveryPresetMode { mode, p, _ -> + val scheme = resolveColorScheme(ThemePreset.valueOf(mode.substringBefore(" ")), mode.endsWith("true")) + if (contrast(scheme.primary, p.card) >= 3f) { + assertTrue("$mode action must be primary", p.action == scheme.primary) + assertTrue( + "$mode glyph must be white or onPrimary", + p.onAction == scheme.onPrimary || p.onAction == GlyphWhite, + ) + } + } + } + @Test fun controlsAndCardEdgeStayVisibleInEveryPresetMode() { forEveryPresetMode { mode, p, background -> From c5bd457da3cb59990167912f1323ab1b14eba979 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:33:08 +0300 Subject: [PATCH 092/107] fix(chat): keep Send primary while streaming, Stop in the flat slot (#1412) --- .../chat/components/ComposerReadinessTest.kt | 28 ++++++++++++++++-- .../ui/chat/components/ComposerToolbar.kt | 29 ++++++++++--------- 2 files changed, 42 insertions(+), 15 deletions(-) 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 fc075cf0..cfd5c863 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 @@ -50,8 +50,8 @@ class ComposerReadinessTest { 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. + // Ready + typing = an interruptible generation: Send keeps the primary + // action slot and Stop moves to the flat slot beside it. compose.onNodeWithTag("stop_button").assertExists() compose.onNodeWithTag("send_button").assertIsEnabled().performClick() compose.runOnIdle { assertEquals(1, sends) } @@ -60,4 +60,28 @@ class ComposerReadinessTest { compose.onNodeWithTag("send_button").assertIsNotEnabled() compose.onNodeWithTag("chat_input").assertTextEquals("held draft") } + + @Test + fun emptyDraftWhileStreamingMakesStopThePrimaryAction() { + compose.setContent { + var input by remember { mutableStateOf(TextFieldValue("")) } + ChatInputBar( + inputFieldValue = input, + onInputChange = { input = it }, + onSend = {}, + onMicTap = {}, + isListening = false, + isAgentTyping = true, + canInterrupt = true, + isConnected = true, + commandCatalog = CommandCatalog(), + isSessionReady = true, + ) + } + compose.onNodeWithTag("stop_button").assertExists() + compose.onNodeWithTag("send_button").assertDoesNotExist() + compose.onNodeWithTag("chat_input").performTextInput("next") + compose.onNodeWithTag("send_button").assertExists() + compose.onNodeWithTag("stop_button").assertExists() + } } 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 6b47a49c..a2d03993 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 @@ -501,11 +501,12 @@ fun ComposerToolbar( } } - // 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). + // Flat slot — mic while idle. While a generation can be interrupted and a + // draft exists, Stop moves here so the rightmost action button stays + // Send (desktop parity: a payload keeps Send primary mid-turn; an empty + // draft makes Stop primary). 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), @@ -513,17 +514,16 @@ fun ComposerToolbar( ) { if (canInterrupt) { FilledIconButton( - onClick = onSend, - enabled = canSend, + onClick = onStopGeneration, colors = flatIconButtonColors(palette), modifier = Modifier .size(ControlSize) - .testTag("send_button"), + .testTag("stop_button"), ) { Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.chat_send_desc), + imageVector = Icons.Default.Stop, + contentDescription = stringResource(R.string.chat_voice_stop_generating), ) } } else { @@ -560,6 +560,9 @@ fun ComposerToolbar( // While a recording is locked this button finishes the voice note, so // it must stay enabled even when the draft alone would not send // (review, PR #1280). + // Stop owns the action button only when there is no draft to send; + // with a draft it lives in the flat slot above. + val actionStops = canInterrupt && !showSend val actionEnabled = if (isVoiceNoteLocked) { true @@ -588,7 +591,7 @@ fun ComposerToolbar( // (review, PR #1280). isVoiceNoteLocked -> onMicTap() - canInterrupt -> onStopGeneration() + actionStops -> onStopGeneration() showSend -> onSend() @@ -598,7 +601,7 @@ fun ComposerToolbar( ).testTag( when { isVoiceNoteLocked -> "voice_note_send_button" - canInterrupt -> "stop_button" + actionStops -> "stop_button" showSend -> "send_button" isListening -> "mic_stop_button" else -> "mic_button" @@ -610,7 +613,7 @@ fun ComposerToolbar( targetState = when { isVoiceNoteLocked -> ActionGlyph.SEND - canInterrupt -> ActionGlyph.STOP + actionStops -> ActionGlyph.STOP showSend -> ActionGlyph.SEND isListening -> ActionGlyph.STOP else -> ActionGlyph.VOICE From 9f033ac82cba249721b6ee3820f71ad2aafa06e6 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:16:23 +0300 Subject: [PATCH 093/107] fix(chat): keep interrupted replies and notices in place (#1413) --- .../hermescontrol/ui/chat/ChatViewModel.kt | 19 +++- .../ui/chat/ChatWsEventReducer.kt | 4 +- .../hermescontrol/ui/chat/StreamingState.kt | 2 + .../ui/chat/ChatPagingMergeTest.kt | 94 +++++++++++++++++++ .../ui/chat/ChatViewModelTest.kt | 35 +++++++ .../ui/chat/ChatWsEventReducerTest.kt | 37 ++++++++ 6 files changed, 188 insertions(+), 3 deletions(-) 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 c2d46f96..027ba385 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 @@ -1117,6 +1117,9 @@ class ChatViewModel( } is WsEvent.MessageToken -> { + // The interrupted partial is already sealed. Wait for its authoritative + // completion instead of letting trailing deltas create a second stream. + if (_streamingState.value.interruptedMessage != null && isCurrentSession(event.sessionId)) return if (isCurrentSession(event.sessionId)) mainTurnBusy = true streamingController.handleMessageToken(event) } @@ -2019,9 +2022,23 @@ class ChatViewModel( 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. + streamingController.flushPendingTransition() + val interruptedStream = _streamingState.value sealStreamingMessageIfAny() + val interruptedMessage = + interruptedStream.streamingMessage?.let { stream -> + _uiState.value.messages.firstOrNull { it.id == stream.id } + } _uiState.update { it.copy(isAgentTyping = false) } - _streamingState.update { StreamingState() } + _streamingState.update { + interruptedStream.copy( + streamingMessage = null, + interruptedMessage = interruptedMessage, + isThinking = false, + thinkingText = "", + isReasoning = false, + ) + } streamingController.resetStreaming() if (status == "interrupted") addSystemMessage("Session interrupted") mainTurnBusy = false 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 1a0c983b..9c2b5e04 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 @@ -457,7 +457,7 @@ object ChatWsEventReducer { val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot) val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline) - val streaming = streamingState.streamingMessage + val streaming = streamingState.interruptedMessage ?: streamingState.streamingMessage // Prefer the authoritative reasoning carried in the message.complete // payload (the gateway's assembled full trace), then fall back to // whatever reasoning.delta deltas accumulated during streaming. @@ -663,7 +663,7 @@ object ChatWsEventReducer { streamingState.streamingMessage ?: return ReducerResult( state = state.copy(isAgentTyping = false), - streamingState = StreamingState(), + streamingState = streamingState.takeIf { it.interruptedMessage != null } ?: StreamingState(), ) val reasoning = if (streamingState.reasoningText.isNotBlank()) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt index 03ef553d..24c01180 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt @@ -4,6 +4,8 @@ import com.m57.hermescontrol.data.model.UsageSnapshotResponse data class StreamingState( val streamingMessage: ChatMessage? = null, + /** Sealed by an interrupt acknowledgement; late completion must update this row, not append another. */ + val interruptedMessage: ChatMessage? = null, val isThinking: Boolean = false, val thinkingText: String = "", val isReasoning: Boolean = false, 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 69b18af1..9f4ffe28 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 @@ -952,6 +952,100 @@ class ChatPagingMergeTest { ) } + private fun laterTurnRows(assistantText: String) = + listOf( + SessionMessage( + id = 0, + role = "user", + content = JsonPrimitive("long running task"), + timestamp = JsonPrimitive(1), + ), + SessionMessage( + id = 1, + role = "assistant", + content = JsonPrimitive(assistantText), + timestamp = JsonPrimitive(2), + ), + SessionMessage( + id = 2, + role = "user", + content = JsonPrimitive("next question"), + timestamp = JsonPrimitive(3), + ), + SessionMessage( + id = 3, + role = "assistant", + content = JsonPrimitive("next answer"), + timestamp = JsonPrimitive(4), + ), + ) + + @Test + fun stopNoticesStayInPlaceWhenInterruptedReplyIsStillLiveOnly() { + val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task") + val liveReply = ChatMessage(id = "live-reply", role = MessageRole.ASSISTANT, content = "Working on it...") + val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop") + val processes = + ChatMessage(id = "uuid-procs", role = MessageRole.SYSTEM, content = "Stopped 2 background processes.") + val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted") + + val merged = + applyServerPage(listOf(prompt, liveReply, stop, processes, interrupted), laterTurnRows("Working on it...")) + + assertEquals( + listOf( + "rest-session-0", + "rest-session-1", + "uuid-stop", + "uuid-procs", + "uuid-int", + "rest-session-2", + "rest-session-3", + ), + merged.map { it.canonicalRestId ?: it.id }, + ) + } + + @Test + fun stopNoticesStayInPlaceWhenLongInterruptedReplyIsAPrefixOfTheServerCopy() { + val long = "Working on the migration plan step by step, first the schema then the data" + val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task") + val liveReply = ChatMessage(id = "live-reply", role = MessageRole.ASSISTANT, content = long) + val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop") + val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted") + + val merged = applyServerPage(listOf(prompt, liveReply, stop, interrupted), laterTurnRows("$long, then indexes")) + + val ids = merged.map { it.canonicalRestId ?: it.id } + assertTrue("notices before later turns: $ids", ids.indexOf("uuid-int") < ids.indexOf("rest-session-2")) + } + + @Test + fun processResultArrivingAfterInterruptNoticeStaysBeforeLaterTurns() { + val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task") + val reply = ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "Working on it...") + val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop") + val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted") + val processes = + ChatMessage(id = "uuid-procs", role = MessageRole.SYSTEM, content = "No background processes to stop.") + + val merged = + applyServerPage(listOf(prompt, reply, stop, interrupted, processes), laterTurnRows("Working on it...")) + + assertEquals( + listOf( + "rest-session-0", + "rest-session-1", + "uuid-stop", + "uuid-int", + "uuid-procs", + "rest-session-2", + "rest-session-3", + ), + merged.map { it.canonicalRestId ?: it.id }, + ) + } + @Test fun commandEchoAndOutputStayInPlaceAcrossFutureSyncs() { val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "what model are you?") 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 7a35896f..65c97925 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 @@ -3922,6 +3922,41 @@ class ChatViewModelTest { verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Fresh turn", any(), false) } } + @Test + fun lateCompletionAfterInterruptUpdatesSealedReplyInPlace() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit(WsEvent.MessageToken("partial reply", sessionId)) + advanceUntilIdle() + val originalId = + viewModel.streamingState.value.streamingMessage!! + .id + + viewModel.interruptSession() + advanceUntilIdle() + val id = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second + mockEventsFlow.emit(WsEvent.RpcResult(id, mapOf("status" to "interrupted"))) + advanceUntilIdle() + assertNull(viewModel.streamingState.value.streamingMessage) + + mockEventsFlow.emit(WsEvent.MessageToken("late tail", sessionId)) + advanceUntilIdle() + assertNull(viewModel.streamingState.value.streamingMessage) + mockEventsFlow.emit(WsEvent.MessageComplete("partial reply with final tail", sessionId)) + advanceUntilIdle() + + val messages = viewModel.uiState.value.messages + val replies = messages.filter { it.role == MessageRole.ASSISTANT } + assertEquals(1, replies.size) + assertEquals(originalId, replies.single().id) + assertEquals("partial reply with final tail", replies.single().content) + assertTrue( + messages.indexOf(replies.single()) < messages.indexOfFirst { it.content == "Session interrupted" }, + ) + assertNull(viewModel.streamingState.value.interruptedMessage) + } + @Test fun hardInterruptWaitsForAckBeforeSubmitting() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index 6d3dc887..fc25edfa 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -9,6 +9,43 @@ import org.junit.Assert.assertTrue import org.junit.Test class ChatWsEventReducerTest { + @Test + fun interruptedIdentitySurvivesDoneButNotANewStart() { + val partial = ChatMessage(id = "partial", role = MessageRole.ASSISTANT, content = "old reply") + val state = ChatUiState(currentSessionId = "session-1", messages = listOf(partial)) + val interrupted = StreamingState(interruptedMessage = partial) + val done = + ChatWsEventReducer.reduce( + state, + interrupted, + WsEvent.MessageDone("session-1"), + "session-1", + ) + assertEquals(partial, done.streamingState.interruptedMessage) + val started = + ChatWsEventReducer.reduce( + done.state, + done.streamingState, + WsEvent.MessageStart("session-1"), + "session-1", + ) + assertNull(started.streamingState.interruptedMessage) + val complete = + ChatWsEventReducer.reduce( + started.state, + started.streamingState, + WsEvent.MessageComplete("new reply", "session-1"), + "session-1", + ) + assertEquals( + "old reply", + complete.state.messages + .first() + .content, + ) + assertEquals(2, complete.state.messages.count { it.role == MessageRole.ASSISTANT }) + } + @Test fun testMessageComplete_clearsResolvedClarifyRequest() { val state = From dcf62c0c0207c8f6469df83b0a9c7fb53bbc6257 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:14:59 +0300 Subject: [PATCH 094/107] fix(chat): keep restored commands in place on session load (#1414) --- .../ui/chat/ChatTranscriptMerge.kt | 6 ++ .../ui/chat/ChatPagingMergeTest.kt | 55 +++++++++++++++++++ 2 files changed, 61 insertions(+) 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 9ebae364..f3f79c10 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 @@ -539,6 +539,7 @@ private fun List.inTranscriptOrder( 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 + val timedCanonical = mapNotNull { m -> m.canonicalOrder?.takeIf { it >= 0L }?.let { m.timestamp to it } } var precedingCanonical: Long? = null var hasPendingPredecessor = false var pendingLocalOrder: Long? = null @@ -550,6 +551,11 @@ private fun List.inTranscriptOrder( precedingCanonical = order hasPendingPredecessor = false pendingLocalOrder = null + } else if (message.localOrder != null && message.isPermanentlyLocal()) { + // Restored from Room, where local rows sort after every server row. Seat it by time after the + // last confirmed row that is not newer, or before the loaded window when it predates it. + localAnchors[message.id] = + timedCanonical.filter { it.first <= message.timestamp }.maxOfOrNull { it.second } ?: beforeCanonical } else if (message.isPermanentlyLocal()) { localAnchors[message.id] = if (hasPendingPredecessor) { 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 9f4ffe28..de249a3d 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 @@ -1209,4 +1209,59 @@ class ChatPagingMergeTest { chronological = !older, preserveLiveIds = true, ) + + @Test + fun restoredLocalCommandsKeepTheirChronologicalPlaceInCachedPage() { + fun row( + id: String, + role: MessageRole, + content: String, + ts: Long, + local: Long? = null, + ) = ChatMessage(id = id, role = role, content = content, timestamp = ts, localOrder = local) + // Room order: confirmed rows first, then every local row (sort_group 1). + val cachedPage = + listOf( + row("rest-s-1", MessageRole.USER, "hi", 10L), + row("rest-s-2", MessageRole.ASSISTANT, "hello", 11L), + row("rest-s-3", MessageRole.USER, "more", 30L), + row("rest-s-4", MessageRole.ASSISTANT, "ok", 31L), + row("rest-s-5", MessageRole.USER, "again", 50L), + row("rest-s-6", MessageRole.ASSISTANT, "sure", 51L), + row("cmd-a", MessageRole.USER, "/help", 12L, local = 1L), + row("cmd-b", MessageRole.USER, "/usage", 32L, local = 2L), + row("cmd-c", MessageRole.USER, "/model", 52L, local = 3L), + ) + + val merged = mergeCachedTranscriptPage(cachedPage, emptyList()) + + assertEquals( + listOf("rest-s-1", "rest-s-2", "cmd-a", "rest-s-3", "rest-s-4", "cmd-b", "rest-s-5", "rest-s-6", "cmd-c"), + merged.map { it.id }, + ) + } + + @Test + fun restoredCommandOlderThanLoadedWindowStaysAboveItNotAtTheTail() { + val server = + listOf( + ChatMessage(id = "rest-s-50", role = MessageRole.USER, content = "a", timestamp = 500L), + ChatMessage(id = "rest-s-51", role = MessageRole.ASSISTANT, content = "b", timestamp = 501L), + ) + val old = + ChatMessage( + id = "cmd", + role = MessageRole.USER, + content = "/model x", + timestamp = 100L, + localOrder = 9L, + ) + val merged = mergeCachedTranscriptPage(server + old, emptyList()) + assertEquals(listOf("cmd", "rest-s-50", "rest-s-51"), merged.map { it.id }) + // Stable when an older page arrives afterwards. + val older = ChatMessage(id = "rest-s-10", role = MessageRole.USER, content = "o", timestamp = 50L) + val olderMid = ChatMessage(id = "rest-s-11", role = MessageRole.ASSISTANT, content = "p", timestamp = 150L) + val more = mergeCachedTranscriptPage(listOf(older, olderMid), merged) + assertEquals(listOf("rest-s-10", "cmd", "rest-s-11", "rest-s-50", "rest-s-51"), more.map { it.id }) + } } From 5145a3354b4125b3b5210220867558ad1b4615bc Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:12:57 +0300 Subject: [PATCH 095/107] perf(markdown): coalesce long plain bullet runs (#1415) --- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 55 +++- .../ui/chat/markdown/MarkdownBulletRun.kt | 121 +++++++++ .../ui/chat/markdown/MarkdownInlineText.kt | 3 +- .../ui/chat/MarkdownBulletRunBenchmark.kt | 76 ++++++ .../ui/chat/MarkdownBulletRunTest.kt | 240 ++++++++++++++++++ .../ui/chat/MarkdownTextBenchmark.kt | 16 ++ 6 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownBulletRun.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunBenchmark.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunTest.kt 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 6c91c1d9..aa2c60d6 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 @@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation @@ -43,6 +44,7 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration @@ -61,6 +63,7 @@ import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors +import com.m57.hermescontrol.ui.chat.markdown.BulletRun import com.m57.hermescontrol.ui.chat.markdown.FnNote import com.m57.hermescontrol.ui.chat.markdown.InlineMathSegment import com.m57.hermescontrol.ui.chat.markdown.MarkdownInlineStyler @@ -69,6 +72,8 @@ import com.m57.hermescontrol.ui.chat.markdown.MarkdownMediaResolver import com.m57.hermescontrol.ui.chat.markdown.MarkdownTable import com.m57.hermescontrol.ui.chat.markdown.MdBlock import com.m57.hermescontrol.ui.chat.markdown.TableAlign +import com.m57.hermescontrol.ui.chat.markdown.buildBulletRunText +import com.m57.hermescontrol.ui.chat.markdown.coalesceBulletRuns import com.m57.hermescontrol.ui.chat.markdown.parseBlocks import com.m57.hermescontrol.ui.chat.markdown.splitInlineMath import com.m57.hermescontrol.util.BidiUtils @@ -100,7 +105,7 @@ fun MarkdownText( val statusColors = LocalHermesStatusColors.current val highlights = searchHighlightColors(statusColors) val linkColor = MaterialTheme.colorScheme.primary - val blocks = remember(text, isStreaming) { parseBlocks(text) } + val blocks = remember(text, isStreaming) { coalesceBulletRuns(parseBlocks(text)) } val latexMeasurer = rememberLatexMeasurer() Column(modifier = modifier.fillMaxWidth()) { @@ -151,6 +156,7 @@ fun MarkdownText( CompositionLocalProvider(LocalLayoutDirection provides blockDirection) { MarkdownInlineText( text = block.text, + isRtlOverride = isRtl, textColor = textColor, latexMeasurer = latexMeasurer, style = @@ -167,6 +173,47 @@ fun MarkdownText( } } + is BulletRun -> { + val density = LocalDensity.current + val bodyStyle = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Ltr) + val textMeasurer = rememberTextMeasurer() + val runText = + remember( + block, + density, + bodyStyle, + textColor, + searchQuery, + isCurrentMatch, + linkColor, + highlights, + ) { + buildBulletRunText( + run = block, + density = density, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + prefixWidth = { prefix -> + with(density) { + textMeasurer + .measure(prefix, bodyStyle) + .size.width + .toSp() + } + }, + ) + } + Text( + text = runText, + color = textColor, + style = bodyStyle, + modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp), + ) + } + is MdBlock.Bullet -> { val indent = (block.level * 16).dp val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) } @@ -195,6 +242,7 @@ fun MarkdownText( Column(modifier = Modifier.weight(1f)) { MarkdownInlineText( text = block.text, + isRtlOverride = isRtl, textColor = textColor, latexMeasurer = latexMeasurer, style = @@ -254,6 +302,7 @@ fun MarkdownText( Column(modifier = Modifier.weight(1f)) { MarkdownInlineText( text = block.text, + isRtlOverride = isRtl, textColor = textColor, latexMeasurer = latexMeasurer, style = @@ -301,6 +350,7 @@ fun MarkdownText( Column(modifier = Modifier.weight(1f)) { MarkdownInlineText( text = block.text, + isRtlOverride = isRtl, textColor = textColor, latexMeasurer = latexMeasurer, style = @@ -347,6 +397,7 @@ fun MarkdownText( if (nestedBlocks.size == 1 && nestedBlocks.single() is MdBlock.Paragraph) { MarkdownInlineText( text = block.text, + isRtlOverride = isRtl, textColor = textColor, latexMeasurer = latexMeasurer, style = @@ -502,6 +553,7 @@ fun MarkdownText( ) MarkdownInlineText( text = note.text, + isRtlOverride = isRtl, textColor = textColor, latexMeasurer = latexMeasurer, style = @@ -526,6 +578,7 @@ fun MarkdownText( CompositionLocalProvider(LocalLayoutDirection provides blockDirection) { MarkdownInlineText( text = block.text, + isRtlOverride = isRtl, textColor = textColor, latexMeasurer = latexMeasurer, style = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownBulletRun.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownBulletRun.kt new file mode 100644 index 00000000..3fbe8b6f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownBulletRun.kt @@ -0,0 +1,121 @@ +package com.m57.hermescontrol.ui.chat.markdown + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextIndent +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.theme.SearchHighlightColors +import com.m57.hermescontrol.util.BidiUtils + +/** + * Render-time optimization, not parsed Markdown: a long run of plain bullets drawn as one text node. + * Produced only by [coalesceBulletRuns]; kept out of the parser's AST file on purpose. + */ +data class BulletRun( + val items: List, +) : MdBlock + +/** Runs shorter than this keep the per-row renderer; the win only matters for very long lists. */ +internal const val MIN_BULLET_RUN = 16 + +/** + * A bullet is eligible for coalescing when it is a plain, LTR, single-line item: no nested content + * and no inline math (math needs per-item inline content that a merged paragraph run can't host). + */ +private fun MdBlock.Bullet.isPlain(): Boolean = + nestedSource.isEmpty() && + !BidiUtils.isRtlText(text) && + splitInlineMath(text).none { it is InlineMathSegment.Math } + +/** + * Replaces each run of at least [minRun] consecutive plain bullets with a single [MdBlock.BulletRun]. + * Everything else is passed through untouched, so complex lists keep the existing renderer. + */ +internal fun coalesceBulletRuns( + blocks: List, + minRun: Int = MIN_BULLET_RUN, +): List { + if (blocks.size < minRun) return blocks + val out = ArrayList(blocks.size) + var i = 0 + while (i < blocks.size) { + val block = blocks[i] + if (block is MdBlock.Bullet && block.isPlain()) { + var end = i + 1 + while (end < blocks.size) { + val next = blocks[end] + if (next is MdBlock.Bullet && next.isPlain()) end++ else break + } + if (end - i >= minRun) { + out.add(BulletRun(blocks.subList(i, end).map { it as MdBlock.Bullet })) + } else { + out.addAll(blocks.subList(i, end)) + } + i = end + } else { + out.add(block) + i++ + } + } + return out +} + +private const val BULLET_LEVEL_INDENT_DP = 16 + +/** The two non-breaking spaces that separate the glyph from the body. */ +internal const val BULLET_PREFIX_PAD = "\u00A0\u00A0" + +internal fun bulletGlyph(level: Int): String = + when (level % 3) { + 0 -> "\u2022" + 1 -> "\u25E6" + else -> "\u25AA" + } + +/** + * Builds the merged text for a [BulletRun]. + * + * The list-level offset is dp-based (like the per-row renderer's `padding(start = (level * 16).dp)`). + * The hanging indent for wrapped lines also includes the rendered bullet prefix, whose width scales with + * the font; [prefixWidth] measures a prefix in the body text style so wrapped lines line up with the body + * at any font scale. + */ +internal fun buildBulletRunText( + run: BulletRun, + density: Density, + textColor: Color, + searchQuery: String, + isCurrentMatch: Boolean, + linkColor: Color, + highlights: SearchHighlightColors, + prefixWidth: (String) -> TextUnit, +): AnnotatedString = + buildAnnotatedString { + run.items.forEachIndexed { index, item -> + val start = with(density) { (item.level * BULLET_LEVEL_INDENT_DP).dp.toSp() } + val prefix = bulletGlyph(item.level) + BULLET_PREFIX_PAD + val rest = (start.value + prefixWidth(prefix).value).sp + withStyle(ParagraphStyle(textIndent = TextIndent(firstLine = start, restLine = rest))) { + append(prefix) + append( + MarkdownInlineStyler.parseInlineSource( + text = item.text, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = false, + ), + ) + // ParagraphStyle breaks lines visually but adds no character; the newline keeps copy/select faithful. + if (index < run.items.lastIndex) append('\n') + } + } + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt index 6270472b..6a96c1a5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt @@ -30,8 +30,9 @@ fun MarkdownInlineText( linkColor: Color, highlights: SearchHighlightColors, modifier: Modifier = Modifier, + isRtlOverride: Boolean? = null, ) { - val isRtl = remember(text) { BidiUtils.isRtlText(text) } + val isRtl = isRtlOverride ?: remember(text) { BidiUtils.isRtlText(text) } val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current val resolvedStyle = style.copy( diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunBenchmark.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunBenchmark.kt new file mode 100644 index 00000000..73526acd --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunBenchmark.kt @@ -0,0 +1,76 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.StatusBlue +import com.m57.hermescontrol.theme.StatusBlueContainer +import com.m57.hermescontrol.theme.StatusGreen +import com.m57.hermescontrol.theme.StatusGreenContainer +import com.m57.hermescontrol.theme.StatusRed +import com.m57.hermescontrol.theme.StatusRedContainer +import com.m57.hermescontrol.theme.StatusYellow +import com.m57.hermescontrol.theme.StatusYellowContainer +import com.m57.hermescontrol.theme.searchHighlightColors +import com.m57.hermescontrol.ui.chat.markdown.BulletRun +import com.m57.hermescontrol.ui.chat.markdown.buildBulletRunText +import com.m57.hermescontrol.ui.chat.markdown.coalesceBulletRuns +import com.m57.hermescontrol.ui.chat.markdown.parseBlocks +import org.junit.Test +import kotlin.system.measureNanoTime + +/** Diagnostic only (opt-in via -PincludeBenchmarks): per-update cost while a list streams from 1 to 1000 bullets. */ +class MarkdownBulletRunBenchmark { + private val highlights = + searchHighlightColors( + HermesStatusColors( + success = StatusGreen, + successContainer = StatusGreenContainer, + onSuccess = Color.White, + warning = StatusYellow, + warningContainer = StatusYellowContainer, + onWarning = Color.White, + error = StatusRed, + errorContainer = StatusRedContainer, + onError = Color.White, + info = StatusBlue, + infoContainer = StatusBlueContainer, + onInfo = Color.White, + ), + ) + + private fun coalesce(md: String) = coalesceBulletRuns(parseBlocks(md)) + + private fun bullets(n: Int) = (1..n).joinToString("\n") { "- item $it" } + + private fun render(run: BulletRun): AnnotatedString = + buildBulletRunText(run, Density(1f), Color.Black, "", false, Color.Blue, highlights) { (it.length * 7).sp } + + @Test + fun benchmarkStreamingGrowth() { + val sizes = listOf(100, 250, 500, 750, 1000) + val sb = StringBuilder() + var done = 0 + val results = mutableListOf() + // warm the JIT + repeat(30) { coalesce(bullets(1000)) } + for (target in sizes) { + while (done < target) { + sb.append("- item ${++done}\n") + } + val md = sb.toString() + val n = 50 + val parse = measureNanoTime { repeat(n) { parseBlocks(md) } } / n / 1e6 + val full = + measureNanoTime { + repeat(n) { + (coalesce(md).firstOrNull { it is BulletRun } as? BulletRun)?.let { render(it) } + } + } / n / 1e6 + results += "$target bullets: parse=${"%.2f".format(parse)}ms parse+coalesce+build=${"%.2f".format(full)}ms" + } + results.forEach { println("BENCHMARK_RESULT: stream $it") } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunTest.kt new file mode 100644 index 00000000..f42b8f1a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunTest.kt @@ -0,0 +1,240 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.StatusBlue +import com.m57.hermescontrol.theme.StatusBlueContainer +import com.m57.hermescontrol.theme.StatusGreen +import com.m57.hermescontrol.theme.StatusGreenContainer +import com.m57.hermescontrol.theme.StatusRed +import com.m57.hermescontrol.theme.StatusRedContainer +import com.m57.hermescontrol.theme.StatusYellow +import com.m57.hermescontrol.theme.StatusYellowContainer +import com.m57.hermescontrol.theme.searchHighlightColors +import com.m57.hermescontrol.ui.chat.markdown.BulletRun +import com.m57.hermescontrol.ui.chat.markdown.MIN_BULLET_RUN +import com.m57.hermescontrol.ui.chat.markdown.MdBlock +import com.m57.hermescontrol.ui.chat.markdown.buildBulletRunText +import com.m57.hermescontrol.ui.chat.markdown.coalesceBulletRuns +import com.m57.hermescontrol.ui.chat.markdown.parseBlocks +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +private val HIGHLIGHTS = + searchHighlightColors( + HermesStatusColors( + success = StatusGreen, + successContainer = StatusGreenContainer, + onSuccess = Color.White, + warning = StatusYellow, + warningContainer = StatusYellowContainer, + onWarning = Color.White, + error = StatusRed, + errorContainer = StatusRedContainer, + onError = Color.White, + info = StatusBlue, + infoContainer = StatusBlueContainer, + onInfo = Color.White, + ), + ) + +private fun bullets( + n: Int, + text: (Int) -> String = { "item $it" }, +) = (1..n).joinToString("\n") { "- ${text(it)}" } + +private fun coalesce(md: String) = coalesceBulletRuns(parseBlocks(md)) + +/** Stand-in for TextMeasurer: prefix width grows with font size, like the real bullet glyph. */ +private fun fakePrefixWidth(fontSizeSp: Float): (String) -> TextUnit = + { prefix -> (prefix.length * fontSizeSp * 0.5f).sp } + +private fun render( + run: BulletRun, + query: String = "", + density: Density = Density(1f), + fontSizeSp: Float = 14f, +) = buildBulletRunText(run, density, Color.Black, query, false, Color.Blue, HIGHLIGHTS, fakePrefixWidth(fontSizeSp)) + +class MarkdownBulletRunTest { + @Test + fun longPlainRunCoalescesIntoOneBlock() { + val out = coalesce(bullets(1000)) + assertEquals(1, out.size) + assertEquals(1000, (out.single() as BulletRun).items.size) + } + + @Test + fun shortRunStaysUncoalesced() { + val out = coalesce(bullets(MIN_BULLET_RUN - 1)) + assertEquals(MIN_BULLET_RUN - 1, out.size) + assertTrue(out.all { it is MdBlock.Bullet }) + } + + @Test + fun runAtThresholdCoalesces() { + assertTrue(coalesce(bullets(MIN_BULLET_RUN)).single() is BulletRun) + } + + @Test + fun rtlBulletBreaksTheRunAndKeepsOldRenderer() { + val md = bullets(40) { if (it == 20) "مرحبا بالعالم" else "item $it" } + val out = coalesce(md) + assertEquals(3, out.size) + assertEquals(19, (out[0] as BulletRun).items.size) + assertTrue(out[1] is MdBlock.Bullet) + assertEquals(20, (out[2] as BulletRun).items.size) + } + + @Test + fun fullyRtlListNeverCoalesces() { + val out = coalesce(bullets(40) { "مرحبا $it" }) + assertTrue(out.none { it is BulletRun }) + } + + @Test + fun inlineMathBreaksTheRun() { + val out = coalesce(bullets(40) { if (it == 20) "energy \$E=mc^2\$" else "item $it" }) + assertTrue(out[1] is MdBlock.Bullet) + assertEquals(3, out.size) + } + + @Test + fun nestedBulletsStayInTheRunWithLevelGlyphAndIndent() { + val md = bullets(20) + "\n - child\n - grandchild\n" + bullets(20) + val run = coalesce(md).single() as BulletRun + assertEquals(42, run.items.size) + assertEquals( + listOf(0, 1, 2, 0), + listOf(run.items[19], run.items[20], run.items[21], run.items[22]).map { it.level }, + ) + val text = render(run) + val lines = text.text.split("\n") + assertTrue(lines[20].startsWith("\u25E6")) + assertTrue(lines[21].startsWith("\u25AA")) + val indents = + text.paragraphStyles.map { + it.item.textIndent!! + .firstLine.value + } + assertEquals(listOf(0f, 16f, 32f, 0f), listOf(indents[19], indents[20], indents[21], indents[22])) + } + + @Test + fun bulletWithNestedSourceFallsBack() { + val md = bullets(20) + "\n- parent\n ```\n code\n ```\n" + bullets(20) + val out = coalesce(md) + assertTrue(out.any { it is MdBlock.Bullet && it.nestedSource.isNotEmpty() }) + assertTrue(out.filterIsInstance().flatMap { it.items }.all { it.nestedSource.isEmpty() }) + } + + @Test + fun taskAndOrderedItemsNeverJoinARun() { + val md = bullets(20) + "\n- [ ] todo\n1. one\n2. two\n" + bullets(20) + val out = coalesce(md) + assertEquals(2, out.count { it is BulletRun }) + assertTrue(out.any { it is MdBlock.Task }) + assertTrue(out.any { it is MdBlock.Ordered }) + } + + @Test + fun surroundingBlocksAreUntouchedAndOrdered() { + val out = coalesce("# Title\n\n" + bullets(30) + "\n\nTail paragraph") + assertTrue(out.first() is MdBlock.Heading) + assertTrue(out[1] is BulletRun) + assertTrue(out.last() is MdBlock.Paragraph) + } + + @Test + fun mergedTextKeepsEveryLineWithGlyphs() { + val run = coalesce(bullets(20)).single() as BulletRun + val lines = render(run).text.split("\n") + assertEquals(20, lines.size) + assertTrue(lines.all { it.startsWith("\u2022") }) + assertTrue(lines.last().endsWith("item 20")) + } + + @Test + fun linksSurviveMerging() { + val run = + coalesce(bullets(20) { if (it == 5) "see [docs](https://example.com/a) now" else "item $it" }) + .single() as BulletRun + val text = render(run) + val links = text.getLinkAnnotations(0, text.length) + assertEquals(1, links.size) + assertEquals("https://example.com/a", (links.single().item as LinkAnnotation.Url).url) + assertEquals("docs", text.text.substring(links.single().start, links.single().end)) + } + + @Test + fun bareUrlsStayLinks() { + val run = coalesce(bullets(20) { if (it == 3) "https://example.com/x" else "item $it" }).single() as BulletRun + val text = render(run) + assertEquals(1, text.getLinkAnnotations(0, text.length).size) + } + + @Test + fun searchHighlightLandsOnTheMatchOffsets() { + val run = coalesce(bullets(20)).single() as BulletRun + val text = render(run, query = "item 7") + val spans = text.spanStyles.filter { it.item.background != Color.Unspecified } + assertTrue(spans.isNotEmpty()) + spans.forEach { assertEquals("item 7", text.text.substring(it.start, it.end)) } + } + + @Test + fun noHighlightWithoutQuery() { + val run = coalesce(bullets(20)).single() as BulletRun + assertTrue(render(run).spanStyles.none { it.item.background != Color.Unspecified }) + } + + @Test + fun levelOffsetIsPhysicalDpAcrossFontScales() { + val run = coalesce(bullets(20) + "\n - child\n").single() as BulletRun + val normal = render(run, density = Density(2f, fontScale = 1f)) + val big = render(run, density = Density(2f, fontScale = 2f)) + val n = + normal.paragraphStyles[20] + .item.textIndent!! + .firstLine.value + val b = + big.paragraphStyles[20] + .item.textIndent!! + .firstLine.value + // 16dp at density 2 = 32px; sp value shrinks by fontScale so physical size stays 16dp. + assertEquals(n, b * 2f, 0.001f) + } + + @Test + fun hangingIndentIncludesTheFontRelativePrefix() { + val run = coalesce(bullets(20)).single() as BulletRun + val small = + render(run, fontSizeSp = 14f) + .paragraphStyles + .first() + .item.textIndent!! + val large = + render(run, fontSizeSp = 28f) + .paragraphStyles + .first() + .item.textIndent!! + // Wrapped lines must sit right of the first line by the prefix width, which grows with the font. + assertTrue(small.restLine.value > small.firstLine.value) + assertEquals( + (small.restLine.value - small.firstLine.value) * 2f, + large.restLine.value - large.firstLine.value, + 0.001f, + ) + } + + @Test + fun oneParagraphStylePerBullet() { + val run = coalesce(bullets(50)).single() as BulletRun + assertEquals(50, render(run).paragraphStyles.size) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt index cbe5511e..917ae076 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.ui.chat.markdown.parseBlocks import org.junit.Test +import kotlin.system.measureNanoTime import kotlin.system.measureTimeMillis class MarkdownTextBenchmark { @@ -47,4 +48,19 @@ class MarkdownTextBenchmark { println("BENCHMARK_RESULT: parseBlocks 10000 iterations took ${time}ms") } + + @Test + fun benchmarkParseBlocksThousandBullets() { + val md = (1..1000).joinToString("\n") { "- $it" } + + // Cold: first parse in this JVM, before any warmup. + val cold = measureNanoTime { parseBlocks(md) } + + repeat(50) { parseBlocks(md) } + val iterations = 200 + val warmNanos = measureNanoTime { repeat(iterations) { parseBlocks(md) } } + + println("BENCHMARK_RESULT: parseBlocks 1000 bullets cold=${cold / 1_000_000.0}ms") + println("BENCHMARK_RESULT: parseBlocks 1000 bullets warm avg=${warmNanos / iterations / 1_000_000.0}ms") + } } From 9529a7e32c8a03cf3328a6a42a2b14eb5ecd6169 Mon Sep 17 00:00:00 2001 From: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:14:27 -0400 Subject: [PATCH 096/107] feat(#1247): live level meter on the voice recording strip (#1416) --- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 1 + .../ui/chat/components/ChatComposer.kt | 55 +++++++++----- .../ui/chat/components/ChatMediaLaunchers.kt | 24 ++++++ .../components/VoiceNoteRecordingPanel.kt | 73 ++++++++++++++++++- 4 files changed, 129 insertions(+), 24 deletions(-) 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 dcd8fbcf..b0b53a94 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 @@ -869,6 +869,7 @@ fun ChatScreen( isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote, isRecordingVoice = mediaLaunchers.isRecordingVoice, isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked, + amplitudeProvider = mediaLaunchers.amplitudeProvider, onStopGeneration = { viewModel.interruptSession() }, isAgentTyping = state.isAgentTyping, canInterrupt = state.canInterrupt, 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 b73833f3..16a91ddf 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 @@ -125,6 +125,7 @@ fun ChatInputBar( onMicLock: () -> Unit = {}, isVoiceNoteLocked: Boolean = false, isRecordingVoice: Boolean = false, + amplitudeProvider: () -> Float = { 0f }, onStopGeneration: () -> Unit = {}, ) { // Allow sending while the agent is mid-turn or awaiting approval: the @@ -440,6 +441,7 @@ fun ChatInputBar( slideProgress = voiceSlideProgress, locked = isVoiceNoteLocked, onCancel = onMicHoldCancel, + amplitude = amplitudeProvider, modifier = Modifier.matchParentSize(), ) } @@ -490,20 +492,21 @@ fun ChatInputBar( } } } - // Zero-height anchor so the tooltip and lock icon float above the - // card, over the message list, like Telegram's lock control. It sits - // outside the animated card because AnimatedVisibility clips its - // children to the animation bounds. + // Anchor at the BOTTOM-end — directly above the mic button the thumb + // is holding. The earlier top-of-card anchor sat ~130 dp away from the + // thumb and read as unrelated decoration, so the lock affordance went + // unnoticed. The content grows upward (wrapContentHeight(Bottom, + // unbounded)) and the offset lifts it clear of the button top. Box(modifier = Modifier.fillMaxWidth().height(0.dp)) { VoiceNoteLockHintOverlay( visible = isRecordingVoice && !isVoiceNoteLocked, showTextHint = !voiceLockHintDone && voiceSlideProgress.value >= 0.8f, modifier = Modifier - .wrapContentHeight(align = Alignment.Top, unbounded = true) - .align(Alignment.TopEnd) - .offset(y = (-42).dp) - .padding(end = 12.dp), + .wrapContentHeight(align = Alignment.Bottom, unbounded = true) + .align(Alignment.BottomEnd) + .offset(y = (-54).dp) + .padding(end = 6.dp), ) } } @@ -543,18 +546,30 @@ private fun VoiceNoteLockHintOverlay( ) Spacer(modifier = Modifier.width(6.dp)) } - Box( - modifier = - Modifier - .size(36.dp) - .background(palette.control, RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Filled.Lock, - contentDescription = null, - tint = palette.placeholder, - modifier = Modifier.size(18.dp), + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = + Modifier + .size(36.dp) + .background(palette.control, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = palette.placeholder, + modifier = Modifier.size(18.dp), + ) + } + // The track: a short line from the lock circle down toward the mic + // button — the affordance reads as "the thing above your thumb", + // and the direction of the gesture is implied by where it sits. + Box( + modifier = + Modifier + .width(1.5.dp) + .height(12.dp) + .background(palette.placeholder.copy(alpha = 0.6f)), ) } } 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 1d8aa22f..fb013b82 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 @@ -35,6 +35,7 @@ import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlin.math.sqrt class ChatMediaLaunchers( val isListening: Boolean, @@ -48,6 +49,8 @@ class ChatMediaLaunchers( val onMicHoldEnd: () -> Unit = {}, val onMicHoldCancel: () -> Unit = {}, val onMicLock: () -> Unit = {}, + /** Normalized 0–1 mic level while recording; read fresh at draw time. */ + val amplitudeProvider: () -> Float = { 0f }, ) @Composable @@ -334,6 +337,23 @@ fun rememberChatMediaLaunchers( } } + // Mic-level feed for the recording blob (VoiceMicBlob). MediaRecorder's + // maxAmplitude is the PEAK since the last read, so this poller must stay + // the ONLY caller while recording. sqrt compression spreads speech + // dynamics the way Telegram's RMS/1800 normalization does. + val recordingAmplitude = remember { mutableStateOf(0f) } + LaunchedEffect(isRecordingVoice) { + if (isRecordingVoice) { + while (isRecordingVoice) { + recordingAmplitude.value = + sqrt(voiceNoteRecorder.currentAmplitude().coerceAtLeast(0) / 32767f) + delay(AMPLITUDE_POLL_MS) + } + } else { + recordingAmplitude.value = 0f + } + } + val onCameraTap: () -> Unit = { try { val timeStamp = @@ -380,6 +400,10 @@ fun rememberChatMediaLaunchers( onMicHoldEnd = onMicHoldEnd, onMicHoldCancel = onMicHoldCancel, onMicLock = onMicLock, + amplitudeProvider = { recordingAmplitude.value }, ) } } + +/** Telegram samples their amplitude pipeline roughly this fast. */ +private const val AMPLITUDE_POLL_MS = 60L 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 index a214b443..a9ce4ecb 100644 --- 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 @@ -10,15 +10,21 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.requiredWidth 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.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize @@ -26,13 +32,15 @@ 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.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap @@ -62,7 +70,9 @@ import kotlin.math.roundToInt * and forth, and slides left and fades as the gesture drags toward cancel * ([slideProgress] runs 1 → 0 across that drag). Once the gesture slides up to * lock, the hint is replaced by a bold tappable "CANCEL" and the action button - * submits the note. + * submits the note. Behind the hint, a live mic-level meter (bars from the + * original #1250 build, restored) shows the recording level — louder speech + * makes the bars visibly climb; flat bars mean the mic hears silence. */ @Composable internal fun VoiceNoteRecordingPanel( @@ -70,6 +80,7 @@ internal fun VoiceNoteRecordingPanel( modifier: Modifier = Modifier, locked: Boolean = false, onCancel: () -> Unit = {}, + amplitude: () -> Float = { 0f }, ) { val palette = composerPalette() val density = LocalDensity.current @@ -82,6 +93,19 @@ internal fun VoiceNoteRecordingPanel( delay(200) } } + // Level meter: rolling window of the last [VOICE_NOTE_BARS] samples. The + // provider emits at the 60 ms poll cadence with Telegram's smoothing + // already applied; equal values do not re-emit, so silence writes nothing + // (Compose stays idle for instrumented tests). + val levels = remember { mutableStateListOf() } + LaunchedEffect(Unit) { + snapshotFlow { amplitude() }.collect { level -> + levels.add(level) + while (levels.size > VOICE_NOTE_BARS) { + levels.removeAt(0) + } + } + } // Telegram blinks the record dot on a 1200 ms cycle. val blink = rememberInfiniteTransition(label = "voice_dot") val dotAlpha by @@ -126,11 +150,13 @@ internal fun VoiceNoteRecordingPanel( fontWeight = FontWeight.Bold, color = palette.placeholder, ) + Spacer(modifier = Modifier.width(10.dp)) + LevelMeter(levels = levels, tint = palette.placeholder) + Spacer(modifier = Modifier.weight(1f)) BoxWithConstraints( modifier = Modifier - .weight(1f) - .clipToBounds(), + .weight(1f), contentAlignment = Alignment.Center, ) { val containerWidthPx = with(density) { maxWidth.toPx() } @@ -149,15 +175,21 @@ internal fun VoiceNoteRecordingPanel( }.testTag("voice_note_cancel_button"), ) } else { + // requiredWidth(intrinsic): the hint lays out at its natural + // width even when the meter leaves it tight, and the removed + // border clip lets the nudge and the cancel drag overflow the + // box instead of being cut at its edges — the fade carries it. Row( modifier = Modifier + .requiredWidth(IntrinsicSize.Max) .offset { val shift = -containerWidthPx * 0.25f * (1f - progress) val nudge = nudgeDp * density.density * progress IntOffset((shift + nudge).roundToInt(), 0) }.alpha(progress), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, ) { CancelChevron(tint = palette.placeholder) Spacer(modifier = Modifier.width(6.dp)) @@ -172,6 +204,39 @@ internal fun VoiceNoteRecordingPanel( } } +/** + * The live mic-level meter from the original #1250 build: a row of small + * vertical bars, oldest left, newest right; each bar's height tracks its + * sample (4–22 dp). Silences stay flat, loud speech climbs — the immediate + * level feedback the blob could not give. + */ +@Composable +private fun LevelMeter( + levels: SnapshotStateList, + tint: Color, +) { + Row( + modifier = Modifier.height(22.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(tint, RoundedCornerShape(2.dp)), + ) + } + } +} + +/** Number of level-meter bars in the recording panel. */ +private const val VOICE_NOTE_BARS = 14 + /** "Slide to cancel" with the cancel word carrying the visual emphasis. */ @Composable private fun slideToCancelText(palette: ComposerPalette) = From 56bb3a88c2c8e0abef733a56dc848146f6bc2afd Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:14:56 +0300 Subject: [PATCH 097/107] fix(theme): complete color and component contrast contracts (#1417) --- DESIGN.md | 9 +- .../ui/common/StatusBadgeColorTest.kt | 81 +++++ .../ui/common/ThemeComponentGalleryTest.kt | 238 +++++++++++++++ .../hermescontrol/theme/HermesStatusColors.kt | 9 +- .../hermescontrol/theme/PaletteTemplate.kt | 45 +++ .../com/m57/hermescontrol/theme/THEMES.md | 67 +++-- .../java/com/m57/hermescontrol/theme/Theme.kt | 23 +- .../m57/hermescontrol/theme/ThemeRegistry.kt | 26 ++ .../theme/presets/AmoledScheme.kt | 14 + .../theme/presets/CatppuccinScheme.kt | 76 +++-- .../theme/presets/DefaultScheme.kt | 30 +- .../theme/presets/GruvboxScheme.kt | 79 +++-- .../theme/presets/MonochromeScheme.kt | 35 ++- .../hermescontrol/theme/presets/NordScheme.kt | 76 +++-- .../ui/common/SharedComponents.kt | 39 ++- .../components/SessionLiveStatusIndicator.kt | 22 +- .../ui/settings/components/AboutSection.kt | 6 - .../settings/components/AppearanceSection.kt | 96 ++---- .../ui/settings/components/BehaviorSection.kt | 16 - app/src/main/res/values/strings.xml | 1 + .../theme/ThemeComponentContrastTest.kt | 64 ++++ .../hermescontrol/theme/ThemePaletteTest.kt | 279 ++++++++++++------ 22 files changed, 1013 insertions(+), 318 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/common/StatusBadgeColorTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/common/ThemeComponentGalleryTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt diff --git a/DESIGN.md b/DESIGN.md index 86672bcc..9908486b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -23,7 +23,7 @@ Android layout dimensions use `dp`; text sizes, line heights, and tracking use ` | Concern | Authoritative source | | --- | --- | -| Presets and mode selection | [Theme.kt](app/src/main/java/com/m57/hermescontrol/theme/Theme.kt) | +| Presets and mode selection | [ThemeRegistry.kt](app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt), [Theme.kt](app/src/main/java/com/m57/hermescontrol/theme/Theme.kt) | | Material slot mapping | [PaletteTemplate.kt](app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt) | | Palette values | [presets/](app/src/main/java/com/m57/hermescontrol/theme/presets/) | | Semantic status model | [HermesStatusColors.kt](app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt) | @@ -144,8 +144,11 @@ passed an accessibility audit. - **Narrow layouts**: Check long localized labels, keyboard-open states, and scrolling at small widths; density must not hide essential actions. [ThemePaletteTest.kt](app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt) -currently guards error slot pairs, full-bleed prose/header contrast, and theme-mode -invariants. Those tests are useful but do not establish contrast or accessibility +guards normal-text slot pairs, full-bleed prose/header contrast, complete Material +role mapping, registry coverage, fixed-role mode invariance, and theme-mode invariants. +[ThemeComponentContrastTest.kt](app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt) +and the instrumented component gallery also guard enabled Material control states. +Those tests are useful but do not establish contrast or accessibility compliance for every rendered component, alpha blend, or dynamic palette. Record device/emulator steps and observed results for changed UI; explicitly name any gate that could not be verified. diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/common/StatusBadgeColorTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/StatusBadgeColorTest.kt new file mode 100644 index 00000000..a115fa7a --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/StatusBadgeColorTest.kt @@ -0,0 +1,81 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.theme.ThemePreference +import com.m57.hermescontrol.theme.ThemePreset +import com.m57.hermescontrol.theme.resolveStatusColors +import com.m57.hermescontrol.ui.sessions.components.SessionLiveStatusIndicator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class StatusBadgeColorTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun latteBadgesRenderStatusFillsAndMatchingOnColors() { + val types = StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL } + composeTestRule.setContent { + HermesControlTheme(themePreference = ThemePreference.LIGHT, themePreset = ThemePreset.CATPPUCCIN) { + Column { + types.forEach { type -> + StatusBadge(type.name, type, Modifier.testTag("badge_$type")) + } + } + } + } + val colors = resolveStatusColors(ThemePreset.CATPPUCCIN, darkTheme = false) + types.forEach { type -> + val (background, foreground) = requireNotNull(statusBadgeColors(type, colors)) + assertRenderedPair("badge_$type", background.toArgb(), foreground.toArgb()) + } + } + + @Test + fun nordLiveIndicatorsRenderStatusFillsAndMatchingOnColors() { + composeTestRule.setContent { + HermesControlTheme(themePreference = ThemePreference.LIGHT, themePreset = ThemePreset.NORD) { + Column { + SessionLiveStatusIndicator(SessionLiveStatus.WORKING, "working") + SessionLiveStatusIndicator(SessionLiveStatus.WAITING, "waiting") + } + } + } + val colors = resolveStatusColors(ThemePreset.NORD, darkTheme = false) + assertRenderedPair("session_live_status_working", colors.success.toArgb(), colors.onSuccess.toArgb()) + assertRenderedPair("session_live_status_waiting", colors.warning.toArgb(), colors.onWarning.toArgb()) + } + + private fun assertRenderedPair( + tag: String, + background: Int, + foreground: Int, + ) { + // PR #1417: verify pixels as well as the palette mapping, so a renderer + // switching back to accent-on-container cannot leave the contrast test green. + val pixels = composeTestRule.onNodeWithTag(tag).captureToImage().toPixelMap() + assertEquals("$tag must render the status fill", background, pixels[pixels.width / 2, 1].toArgb()) + assertTrue( + "$tag must render the matching on-status text/icon", + (0 until pixels.height).any { y -> + (0 until pixels.width).any { x -> pixels[x, y].toArgb() == foreground } + }, + ) + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/common/ThemeComponentGalleryTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/ThemeComponentGalleryTest.kt new file mode 100644 index 00000000..091862e4 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/ThemeComponentGalleryTest.kt @@ -0,0 +1,238 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.unit.dp +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.theme.ThemePreference +import com.m57.hermescontrol.theme.ThemePreset +import com.m57.hermescontrol.theme.ThemeRegistry +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +@MediumTest +class ThemeComponentGalleryTest( + private val preset: ThemePreset, + private val dark: Boolean, +) { + @get:Rule + val composeTestRule = createComposeRule() + + private var samples = emptyList() + + @Test + fun enabledControlsRenderTheirAccessibleDefaultStateColors() { + composeTestRule.setContent { + HermesControlTheme( + themePreset = preset, + themePreference = if (dark) ThemePreference.DARK else ThemePreference.LIGHT, + ) { + Gallery() + } + } + composeTestRule.waitForIdle() + // Assert the public defaults from the installed Compose version, not a copied token table. + assertTrue("gallery must publish its state contracts", samples.isNotEmpty()) + samples.forEach { sample -> + val a = sample.foreground.luminance() + val b = sample.background.luminance() + val ratio = (maxOf(a, b) + 0.05f) / (minOf(a, b) + 0.05f) + assertTrue("$preset dark=$dark ${sample.tag}: $ratio < ${sample.minimum}", ratio >= sample.minimum) + } + // Pixel evidence catches defaults that differ from the actual rendered state (including focus). + samples.groupBy { it.tag }.forEach { (tag, contracts) -> + val pixels = + composeTestRule + .onNodeWithTag(tag) + .performScrollTo() + .captureToImage() + .toPixelMap() + val rendered = + buildSet { + for (y in 0 until pixels.height) { + for (x in 0 until pixels.width) add(pixels[x, y].toArgb()) + } + } + contracts.forEach { sample -> + assertTrue("$preset dark=$dark $tag must render its foreground", sample.foreground.toArgb() in rendered) + if (sample.checkBackground) { + assertTrue("$preset dark=$dark $tag must render its fill", sample.background.toArgb() in rendered) + } + } + } + } + + @Composable + private fun Gallery() { + val c = MaterialTheme.colorScheme + val switches = SwitchDefaults.colors() + val radios = RadioButtonDefaults.colors() + val checks = CheckboxDefaults.colors() + val sliders = SliderDefaults.colors() + val fields = OutlinedTextFieldDefaults.colors() + val segments = SegmentedButtonDefaults.colors() + val focus = remember { FocusRequester() } + SideEffect { + samples = + listOf( + Sample("switch_on", switches.checkedThumbColor, switches.checkedTrackColor, checkBackground = true), + Sample("switch_on", switches.checkedTrackColor, c.surface), + Sample( + "switch_off", + switches.uncheckedThumbColor, + switches.uncheckedTrackColor, + checkBackground = true, + ), + Sample("switch_off", switches.uncheckedBorderColor, c.surface), + Sample("radio_on", radios.selectedColor, c.surface), + Sample("radio_off", radios.unselectedColor, c.surface), + Sample("check_on", checks.checkedCheckmarkColor, checks.checkedBoxColor, checkBackground = true), + Sample("check_on", checks.checkedBorderColor, c.surface), + Sample("check_off", checks.uncheckedBorderColor, c.surface), + Sample("slider", sliders.activeTrackColor, sliders.inactiveTrackColor, checkBackground = true), + Sample( + "field_normal", + fields.unfocusedIndicatorColor, + fields.unfocusedContainerColor.compositeOver(c.surface), + ), + Sample("field_normal", fields.unfocusedLabelColor, c.surface, 4.5f), + Sample( + "field_focus", + fields.focusedIndicatorColor, + fields.focusedContainerColor.compositeOver(c.surface), + ), + Sample("field_focus", fields.focusedLabelColor, c.surface, 4.5f), + Sample( + "field_error", + fields.errorIndicatorColor, + fields.errorContainerColor.compositeOver(c.surface), + ), + Sample("field_error", fields.errorSupportingTextColor, c.surface, 4.5f), + Sample("segment_on", segments.activeContentColor, segments.activeContainerColor, 4.5f, true), + Sample("segment_on", segments.activeBorderColor, segments.activeContainerColor), + Sample( + "segment_off", + segments.inactiveContentColor, + segments.inactiveContainerColor.compositeOver(c.surface), + 4.5f, + ), + Sample("segment_off", segments.inactiveBorderColor, c.surface), + Sample("primary_text", c.primary, c.surface, 4.5f), + Sample("error_text", c.error, c.surface, 4.5f), + ) + } + Surface(color = c.surface, modifier = Modifier.fillMaxSize()) { + Column(Modifier.verticalScroll(rememberScrollState()).padding(8.dp)) { + Text("$preset ${if (dark) "dark" else "light"}") + Row { + Switch(true, {}, Modifier.testTag("switch_on")) + Switch(false, {}, Modifier.testTag("switch_off")) + RadioButton(true, {}, Modifier.testTag("radio_on")) + RadioButton(false, {}, Modifier.testTag("radio_off")) + } + Row { + Checkbox(true, {}, Modifier.testTag("check_on")) + Checkbox(false, {}, Modifier.testTag("check_off")) + } + Slider(0.5f, {}, Modifier.testTag("slider")) + OutlinedTextField("Value", { + }, Modifier.fillMaxWidth().testTag("field_normal"), readOnly = true, label = { Text("Normal") }) + OutlinedTextField( + "Value", + { + }, + Modifier + .fillMaxWidth() + .focusRequester( + focus, + ).testTag("field_focus"), + readOnly = true, + label = { Text("Focused") }, + ) + OutlinedTextField("Value", { + }, Modifier.fillMaxWidth().testTag("field_error"), readOnly = true, isError = true, label = { + Text("Error") + }, supportingText = { Text("Error text") }) + SingleChoiceSegmentedButtonRow { + SegmentedButton( + true, + {}, + SegmentedButtonDefaults.itemShape(0, 2), + Modifier.testTag("segment_on"), + ) { Text("Selected") } + SegmentedButton( + false, + { + }, + SegmentedButtonDefaults.itemShape( + 1, + 2, + ), + Modifier.testTag("segment_off"), + ) { Text("Unselected") } + } + Text("Primary foreground", Modifier.testTag("primary_text"), color = c.primary) + Text("Error foreground", Modifier.testTag("error_text"), color = c.error) + } + } + LaunchedEffect(Unit) { focus.requestFocus() } + } + + private data class Sample( + val tag: String, + val foreground: Color, + val background: Color, + val minimum: Float = 3f, + val checkBackground: Boolean = false, + ) + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0} dark={1}") + fun presets(): List> = + ThemeRegistry.flatMap { (preset, _) -> listOf(true, false).map { arrayOf(preset, it) } } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt b/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt index 8dee742b..a569b4a2 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt @@ -10,8 +10,13 @@ import androidx.compose.ui.graphics.luminance * Material You changes the *primary* / *surface* palette based on the user's * wallpaper, but success / warning / error / info colours must stay * semantically correct (green = good, red = bad) regardless of what the - * wallpaper-derived palette would choose. This struct is provided via - * [LocalHermesStatusColors] and should be used for all status indicators, + * wallpaper-derived palette would choose. + * + * `onSuccess`/`onWarning`/`onError`/`onInfo` are foregrounds on the matching + * status fills, not on their container colors. Reusable status badges use + * these fill/on pairs; error-container content uses `onErrorContainer`. + * + * Use [LocalHermesStatusColors] for all status indicators, * badges, and semantic feedback colours. */ data class HermesStatusColors( diff --git a/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt b/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt index 7d145e94..e7034bf2 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt @@ -28,6 +28,9 @@ enum class ThemeMode { FULL, DARK_ONLY, LIGHT_ONLY } * * The Material error slots are derived from [status] — the theme author * defines semantic colors once and the scheme inherits them. + * [primary] and the status error are also used directly for text on surfaces. + * [outline] must contrast with [surface] and [surfaceContainerHighest], which + * Material uses as the unchecked switch track. */ data class PaletteColors( val primary: Color, @@ -48,6 +51,20 @@ data class PaletteColors( val onSurface: Color, val surfaceVariant: Color, val onSurfaceVariant: Color, + val surfaceDim: Color, + val surfaceBright: Color, + val primaryFixed: Color, + val primaryFixedDim: Color, + val onPrimaryFixed: Color, + val onPrimaryFixedVariant: Color, + val secondaryFixed: Color, + val secondaryFixedDim: Color, + val onSecondaryFixed: Color, + val onSecondaryFixedVariant: Color, + val tertiaryFixed: Color, + val tertiaryFixedDim: Color, + val onTertiaryFixed: Color, + val onTertiaryFixedVariant: Color, val surfaceContainerLowest: Color, val surfaceContainerLow: Color, val surfaceContainer: Color, @@ -123,6 +140,20 @@ private fun darkSchemeOf(c: PaletteColors): ColorScheme = surfaceVariant = c.surfaceVariant, onSurfaceVariant = c.onSurfaceVariant, surfaceTint = c.primary, + surfaceDim = c.surfaceDim, + surfaceBright = c.surfaceBright, + primaryFixed = c.primaryFixed, + primaryFixedDim = c.primaryFixedDim, + onPrimaryFixed = c.onPrimaryFixed, + onPrimaryFixedVariant = c.onPrimaryFixedVariant, + secondaryFixed = c.secondaryFixed, + secondaryFixedDim = c.secondaryFixedDim, + onSecondaryFixed = c.onSecondaryFixed, + onSecondaryFixedVariant = c.onSecondaryFixedVariant, + tertiaryFixed = c.tertiaryFixed, + tertiaryFixedDim = c.tertiaryFixedDim, + onTertiaryFixed = c.onTertiaryFixed, + onTertiaryFixedVariant = c.onTertiaryFixedVariant, surfaceContainerLowest = c.surfaceContainerLowest, surfaceContainerLow = c.surfaceContainerLow, surfaceContainer = c.surfaceContainer, @@ -161,6 +192,20 @@ private fun lightSchemeOf(c: PaletteColors): ColorScheme = surfaceVariant = c.surfaceVariant, onSurfaceVariant = c.onSurfaceVariant, surfaceTint = c.primary, + surfaceDim = c.surfaceDim, + surfaceBright = c.surfaceBright, + primaryFixed = c.primaryFixed, + primaryFixedDim = c.primaryFixedDim, + onPrimaryFixed = c.onPrimaryFixed, + onPrimaryFixedVariant = c.onPrimaryFixedVariant, + secondaryFixed = c.secondaryFixed, + secondaryFixedDim = c.secondaryFixedDim, + onSecondaryFixed = c.onSecondaryFixed, + onSecondaryFixedVariant = c.onSecondaryFixedVariant, + tertiaryFixed = c.tertiaryFixed, + tertiaryFixedDim = c.tertiaryFixedDim, + onTertiaryFixed = c.onTertiaryFixed, + onTertiaryFixedVariant = c.onTertiaryFixedVariant, surfaceContainerLowest = c.surfaceContainerLowest, surfaceContainerLow = c.surfaceContainerLow, surfaceContainer = c.surfaceContainer, diff --git a/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md b/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md index 01f7dd11..12f57d82 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md +++ b/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md @@ -12,7 +12,8 @@ All theme code is under: ``` app/src/main/java/com/m57/hermescontrol/theme/ -├── Theme.kt # dispatcher — preset lookup + mode fallback +├── Theme.kt # dispatcher — mode fallback + dynamic colors +├── ThemeRegistry.kt # single preset/palette registry ├── PaletteTemplate.kt # the template — the ONE shape every theme follows ├── Color.kt # shared tokens for non-theme code (status fallbacks, code blocks, …) ├── HermesStatusColors.kt # semantic status color model (success/warning/error/info + on*) @@ -55,7 +56,14 @@ val MyTheme = The template maps `PaletteColors` into the Material 3 `ColorScheme` slots. The Material `error` slots (`error`, `onError`, `errorContainer`, `onErrorContainer`) are **derived** from the theme's status set — the theme author defines semantic -colors once. +colors once. `surfaceTint` comes from `primary`. Every other Material color role +is required explicitly, including bright/dim surfaces and all fixed accent roles. +No role may inherit Material baseline colors. Fixed accent and foreground roles +stay identical across a preset’s shipped modes; Fixed/Dim may share an official +swatch when the source palette has no accessible tonal ladder. + +Prefer upstream swatches. Document every derived color beside its declaration +with the official pairing’s measured contrast and why a derived role is needed. ## Preset conventions @@ -69,8 +77,33 @@ colors once. light text would fail contrast. - **Grayscale status colors** (Monochrome, AMOLED) separate success/warning/ error/info by lightness only — consumers must pair with icons or labels. -- Every shipped mode's error slot pairs are enforced at **>= 3:1 contrast** by - `ThemePaletteTest` — a preset that breaks this fails the unit-test gate. +- Normal-text foreground/background pairs, including accent containers, fixed + accents, semantic status fills, errors, and the surface ladder, are enforced at **>= 4.5:1 contrast** + by `ThemePaletteTest`. The separate full-bleed header guard remains >= 3:1. + +Reusable `StatusBadge` and `SessionLiveStatusIndicator` use a shared +`statusBadgeColors` mapping: status fills (`success`, `warning`, `error`, `info`) +with their matching `on*` text/icons. `onSuccess` is not a foreground token for +`successContainer`. Palette tests exercise this exact renderer mapping in every +resolved mode, including fallback modes; neutral badges use Material tokens. + +Enabled Material components also have a component-state contract: +- Graphics/boundaries target >= 3:1: switch thumbs/tracks and borders, radio and + checkbox states, slider active/inactive tracks, text-field borders, and segmented borders. +- Primary/error text and field labels on surface/background target >= 4.5:1. +- Segmented content uses the corresponding container foreground at >= 4.5:1. + +`ThemeComponentContrastTest` checks the pinned Material role combinations in every +resolved preset/mode. `ThemeComponentGalleryTest` reads the installed Compose +public color defaults and verifies rendered pixels for all twelve preset/mode +combinations, including focused/error fields. Disabled controls are deliberately +excluded from the enabled contrast contract; Material uses opacity to mute them. +Settings switches use Material defaults (`primary` track / `onPrimary` thumb), +not the reversed `primaryContainer` track / `primary` thumb combination. + +Outlines must contrast against both surface and the unchecked switch track +(`surfaceContainerHighest`). Named palettes may need documented derived primary +or error tones for direct text; fixed roles still preserve their upstream accents. ## Theme modes @@ -87,37 +120,33 @@ doesn't ship — never to a sibling preset. ## The dispatcher -`Theme.kt` is a **pure lookup** — no special-casing. Given a `ThemePreset` and -a dark flag, `themeFor()` returns the preset's `ThemePalette`, then the scheme / -status are read off it (with the mode fallback above): +`ThemeRegistry.kt` maps every `ThemePreset` to one `ThemePalette`. Resolution, +UI selection, and palette tests use this registry. An exact enum/registry coverage +test rejects missing, duplicate, or reordered entries. Android string resources +stay in the UI’s exhaustive label mapping, outside the palette model. -```kotlin -private fun themeFor(preset: ThemePreset): ThemePalette = when (preset) { - ThemePreset.DEFAULT -> DefaultTheme - ThemePreset.MONOCHROME -> MonochromeTheme - // …one line per preset… -} -``` +`Theme.kt` reads `preset.palette()` and applies the mode fallback above. Dynamic (Material You) color on API 31+ can optionally override the preset scheme when `useDynamicColors = true` (defaults to `false`). Semantic status colors are always resolved from the -active preset via `LocalHermesStatusColors`. +active preset via `LocalHermesStatusColors`. The preset selector stays enabled +and explains that it controls status colors while Material You supplies app colors. ## Adding a new theme 1. **Create** `presets/Scheme.kt` as a template fill (see above) — `buildTheme` for a full theme, `buildThemeDarkOnly` / `buildThemeLightOnly` for single-mode themes. Every status color must be bespoke — no aliasing. -2. **Add 1 line** to `themeFor()` in `Theme.kt` + import the new val. +2. **Register** the new palette in `ThemeRegistry.kt`. 3. **Add the enum entry** `MY_THEME` to `ThemePreset` in `Theme.kt`. -4. **Wire the UI**: add the label + selection in +4. **Wire the UI**: add the exhaustive label mapping in `ui/settings/components/AppearanceSection.kt` (and any string resource). 5. **Verify**: ```bash ./gradlew ktlintCheck testDebugUnitTest ``` - `ThemePaletteTest` asserts >= 3:1 contrast on every shipped mode's error - slot pairs and the ThemeMode invariants — it must stay green. + `ThemePaletteTest` checks text contrast, complete Material slot mapping, + mode-independent fixed roles, registry coverage, and mode/fallback invariants. ## Conventions diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt index f9ce67ff..7931c9ad 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt @@ -10,12 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.platform.LocalContext -import com.m57.hermescontrol.theme.presets.AmoledTheme -import com.m57.hermescontrol.theme.presets.CatppuccinTheme import com.m57.hermescontrol.theme.presets.DefaultTheme -import com.m57.hermescontrol.theme.presets.GruvboxTheme -import com.m57.hermescontrol.theme.presets.MonochromeTheme -import com.m57.hermescontrol.theme.presets.NordTheme import kotlinx.serialization.Serializable @Serializable @@ -28,20 +23,6 @@ val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM } val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT } val LocalChatFontScale = compositionLocalOf { 1.0f } -/** - * The 6 preset themes — one file each, all built from the same - * [PaletteTemplate] shape (see `PaletteTemplate.kt`). - */ -private fun themeFor(preset: ThemePreset): ThemePalette = - when (preset) { - ThemePreset.DEFAULT -> DefaultTheme - ThemePreset.MONOCHROME -> MonochromeTheme - ThemePreset.GRUVBOX -> GruvboxTheme - ThemePreset.CATPPUCCIN -> CatppuccinTheme - ThemePreset.AMOLED -> AmoledTheme - ThemePreset.NORD -> NordTheme - } - /** * Resolve the Material 3 [ColorScheme] for a preset + dark flag. * @@ -53,7 +34,7 @@ internal fun resolveColorScheme( preset: ThemePreset, darkTheme: Boolean, ): ColorScheme { - val theme = themeFor(preset) + val theme = preset.palette() // DefaultTheme is FULL — its scheme is never null for either mode. return theme.schemeFor(darkTheme) ?: requireNotNull(DefaultTheme.schemeFor(darkTheme)) } @@ -66,7 +47,7 @@ internal fun resolveStatusColors( preset: ThemePreset, darkTheme: Boolean, ): HermesStatusColors { - val theme = themeFor(preset) + val theme = preset.palette() // DefaultTheme is FULL — its status colors are never null for either mode. return theme.statusFor(darkTheme) ?: requireNotNull(DefaultTheme.statusFor(darkTheme)) } diff --git a/app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt b/app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt new file mode 100644 index 00000000..9d475bf8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.theme + +import com.m57.hermescontrol.theme.presets.AmoledTheme +import com.m57.hermescontrol.theme.presets.CatppuccinTheme +import com.m57.hermescontrol.theme.presets.DefaultTheme +import com.m57.hermescontrol.theme.presets.GruvboxTheme +import com.m57.hermescontrol.theme.presets.MonochromeTheme +import com.m57.hermescontrol.theme.presets.NordTheme + +internal data class ThemeDefinition( + val preset: ThemePreset, + val palette: ThemePalette, +) + +// Regression theme-contract: selection, resolution, and tests share this registry. +internal val ThemeRegistry = + listOf( + ThemeDefinition(ThemePreset.DEFAULT, DefaultTheme), + ThemeDefinition(ThemePreset.MONOCHROME, MonochromeTheme), + ThemeDefinition(ThemePreset.GRUVBOX, GruvboxTheme), + ThemeDefinition(ThemePreset.CATPPUCCIN, CatppuccinTheme), + ThemeDefinition(ThemePreset.AMOLED, AmoledTheme), + ThemeDefinition(ThemePreset.NORD, NordTheme), + ) + +internal fun ThemePreset.palette(): ThemePalette = ThemeRegistry.single { it.preset == this }.palette diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/AmoledScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/AmoledScheme.kt index e8954976..7ee9b189 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/AmoledScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/AmoledScheme.kt @@ -59,6 +59,20 @@ val AmoledTheme = onSurface = AmoledTextBright, surfaceVariant = AmoledContainerHigh, onSurfaceVariant = AmoledTextDim, + surfaceDim = AmoledFloor, + surfaceBright = AmoledContainerHighest, + primaryFixed = AmoledTextBright, + primaryFixedDim = AmoledTextBright, + onPrimaryFixed = AmoledFloor, + onPrimaryFixedVariant = AmoledFloor, + secondaryFixed = AmoledSecondary, + secondaryFixedDim = AmoledSecondary, + onSecondaryFixed = AmoledFloor, + onSecondaryFixedVariant = AmoledFloor, + tertiaryFixed = AmoledTertiary, + tertiaryFixedDim = AmoledTertiary, + onTertiaryFixed = AmoledFloor, + onTertiaryFixedVariant = AmoledFloor, surfaceContainerLowest = AmoledFloor, surfaceContainerLow = AmoledFloor, surfaceContainer = AmoledContainer, diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/CatppuccinScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/CatppuccinScheme.kt index fc23d57d..11176bfd 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/CatppuccinScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/CatppuccinScheme.kt @@ -19,7 +19,6 @@ private val MochaRed = Color(0xFFF38BA8) private val MochaText = Color(0xFFCDD6F4) private val MochaSubtext1 = Color(0xFFBAC2DE) private val MochaSubtext0 = Color(0xFFA6ADC8) -private val MochaOverlay0 = Color(0xFF6C7086) private val MochaSurface1 = Color(0xFF45475A) private val MochaSurface0 = Color(0xFF313244) private val MochaBase = Color(0xFF1E1E2E) @@ -31,24 +30,33 @@ private val MochaCrust = Color(0xFF11111B) // --------------------------------------------------------------------- private val LatteMauve = Color(0xFF8839EF) + +// Derived role, not an upstream Catppuccin swatch: Latte Text/Pink is 3.02:1 +// and Text/Green is 2.39:1. Deeper ink reaches 5.88:1 and 4.65:1 respectively. +private val LatteDeepInk = Color(0xFF202334) + +// Derived role, not an upstream Catppuccin swatch: Latte Base/Blue is only 4.34:1. +// White/Blue reaches 4.94:1 for normal text. +private val LatteOnBlue = Color(0xFFFFFFFF) private val LatteBlue = Color(0xFF1E66F5) private val LattePink = Color(0xFFEA76CB) private val LatteGreen = Color(0xFF40A02B) private val LatteYellow = Color(0xFFDF8E1D) private val LatteRed = Color(0xFFD20F39) -private val LatteText = Color(0xFF4C4F69) + +// Official Latte Subtext0 keeps borders visible against Surface0 (3.20:1). private val LatteSubtext0 = Color(0xFF6C6F85) -private val LatteOverlay0 = Color(0xFF9CA0B0) +private val LatteText = Color(0xFF4C4F69) private val LatteSurface1 = Color(0xFFBCC0CC) private val LatteSurface0 = Color(0xFFCCD0DA) private val LatteBase = Color(0xFFEFF1F5) private val LatteMantle = Color(0xFFE6E9EF) private val LatteCrust = Color(0xFFDCE0E8) -// Bespoke tint used only for the light primary container — not part of -// the official Catppuccin swatch set, kept local to this file. -private val LattePrimaryContainer = Color(0xFFE6CAFA) -private val LatteOnPrimaryContainer = Color(0xFF380075) +// Regression theme-contract: fixed accents use Mocha in both modes. Fixed/Dim +// share official accents rather than inventing tonal steps; on-roles use Crust. +// Latte surface containers stop at Surface0 so normal text stays >= 4.5:1. +// Upstream: https://github.com/catppuccin/palette/blob/main/palette.json /** Catppuccin theme — Mocha (dark) / Latte (light). */ val CatppuccinTheme = @@ -73,6 +81,20 @@ val CatppuccinTheme = onSurface = MochaText, surfaceVariant = MochaSurface0, onSurfaceVariant = MochaSubtext0, + surfaceDim = MochaBase, + surfaceBright = MochaSurface1, + primaryFixed = MochaMauve, + primaryFixedDim = MochaMauve, + onPrimaryFixed = MochaCrust, + onPrimaryFixedVariant = MochaCrust, + secondaryFixed = MochaBlue, + secondaryFixedDim = MochaBlue, + onSecondaryFixed = MochaCrust, + onSecondaryFixedVariant = MochaCrust, + tertiaryFixed = MochaPink, + tertiaryFixedDim = MochaPink, + onTertiaryFixed = MochaCrust, + onTertiaryFixedVariant = MochaCrust, surfaceContainerLowest = MochaCrust, surfaceContainerLow = MochaMantle, surfaceContainer = MochaBase, @@ -81,7 +103,7 @@ val CatppuccinTheme = inverseSurface = MochaText, inverseOnSurface = MochaBase, inversePrimary = LatteMauve, - outline = MochaOverlay0, + outline = MochaSubtext0, outlineVariant = MochaSurface1, scrim = MochaCrust, status = @@ -105,48 +127,62 @@ val CatppuccinTheme = PaletteColors( primary = LatteMauve, onPrimary = LatteBase, - primaryContainer = LattePrimaryContainer, - onPrimaryContainer = LatteOnPrimaryContainer, + primaryContainer = LatteBase, + onPrimaryContainer = LatteMauve, secondary = LatteBlue, - onSecondary = LatteBase, + onSecondary = LatteOnBlue, secondaryContainer = LatteMantle, onSecondaryContainer = LatteText, tertiary = LattePink, - onTertiary = LatteBase, - tertiaryContainer = LatteSurface1, + onTertiary = LatteDeepInk, + tertiaryContainer = LatteSurface0, onTertiaryContainer = LatteText, background = LatteBase, onBackground = LatteText, surface = LatteBase, onSurface = LatteText, surfaceVariant = LatteSurface0, - onSurfaceVariant = LatteSubtext0, + onSurfaceVariant = LatteText, + surfaceDim = LatteSurface0, + surfaceBright = LatteBase, + primaryFixed = MochaMauve, + primaryFixedDim = MochaMauve, + onPrimaryFixed = MochaCrust, + onPrimaryFixedVariant = MochaCrust, + secondaryFixed = MochaBlue, + secondaryFixedDim = MochaBlue, + onSecondaryFixed = MochaCrust, + onSecondaryFixedVariant = MochaCrust, + tertiaryFixed = MochaPink, + tertiaryFixedDim = MochaPink, + onTertiaryFixed = MochaCrust, + onTertiaryFixedVariant = MochaCrust, surfaceContainerLowest = LatteCrust, surfaceContainerLow = LatteMantle, surfaceContainer = LatteBase, surfaceContainerHigh = LatteSurface0, - surfaceContainerHighest = LatteSurface1, + surfaceContainerHighest = LatteSurface0, inverseSurface = LatteText, inverseOnSurface = LatteBase, inversePrimary = MochaMauve, - outline = LatteOverlay0, + outline = LatteSubtext0, outlineVariant = LatteSurface1, scrim = LatteCrust, status = HermesStatusColors( success = LatteGreen, successContainer = LatteSurface0, - onSuccess = LatteBase, + onSuccess = LatteDeepInk, warning = LatteYellow, warningContainer = LatteSurface0, - onWarning = LatteBase, + onWarning = LatteDeepInk, error = LatteRed, errorContainer = LatteSurface0, onError = LatteBase, - onErrorContainer = LatteRed, + onErrorContainer = LatteText, info = LatteBlue, infoContainer = LatteSurface0, - onInfo = LatteBase, + onInfo = LatteOnBlue, ), ), ) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/DefaultScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/DefaultScheme.kt index fcb7f67c..6fc42111 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/DefaultScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/DefaultScheme.kt @@ -73,6 +73,20 @@ val DefaultTheme = onSurface = SlateDarkFg1, surfaceVariant = SlateDarkBg1, onSurfaceVariant = SlateDarkFg3, + surfaceDim = SlateDarkBg0, + surfaceBright = SlateDarkBg4, + primaryFixed = SlateBrightTeal, + primaryFixedDim = SlateBrightTeal, + onPrimaryFixed = SlateDarkBg0, + onPrimaryFixedVariant = SlateDarkBg0, + secondaryFixed = SlateBrightCyan, + secondaryFixedDim = SlateBrightCyan, + onSecondaryFixed = SlateDarkBg0, + onSecondaryFixedVariant = SlateDarkBg0, + tertiaryFixed = SlateBrightBlue, + tertiaryFixedDim = SlateBrightBlue, + onTertiaryFixed = SlateDarkBg0, + onTertiaryFixedVariant = SlateDarkBg0, surfaceContainerLowest = SlateDarkBg0, surfaceContainerLow = SlateDarkBg1, surfaceContainer = SlateDarkBg2, @@ -121,6 +135,20 @@ val DefaultTheme = onSurface = SlateLightFg1, surfaceVariant = SlateLightBg1, onSurfaceVariant = SlateLightFg3, + surfaceDim = SlateLightBg4, + surfaceBright = SlateLightBg0, + primaryFixed = SlateBrightTeal, + primaryFixedDim = SlateBrightTeal, + onPrimaryFixed = SlateDarkBg0, + onPrimaryFixedVariant = SlateDarkBg0, + secondaryFixed = SlateBrightCyan, + secondaryFixedDim = SlateBrightCyan, + onSecondaryFixed = SlateDarkBg0, + onSecondaryFixedVariant = SlateDarkBg0, + tertiaryFixed = SlateBrightBlue, + tertiaryFixedDim = SlateBrightBlue, + onTertiaryFixed = SlateDarkBg0, + onTertiaryFixedVariant = SlateDarkBg0, surfaceContainerLowest = SlateLightBg0, surfaceContainerLow = SlateLightBg1, surfaceContainer = SlateLightBg2, @@ -129,7 +157,7 @@ val DefaultTheme = inverseSurface = SlateLightFg1, inverseOnSurface = SlateLightBg0, inversePrimary = SlateBrightTeal, - outline = SlateGray, + outline = SlateLightFg3, outlineVariant = SlateLightBg3, scrim = SlateLightBg0, status = diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/GruvboxScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/GruvboxScheme.kt index f56965f6..36fca5a4 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/GruvboxScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/GruvboxScheme.kt @@ -9,18 +9,20 @@ import com.m57.hermescontrol.theme.buildTheme // Gruvbox Dark (medium contrast) — named swatches // --------------------------------------------------------------------- +// Official hard-contrast bg0; medium bg0/red only reaches 4.29:1. +private val GruvboxDarkInk = Color(0xFF1D2021) private val GruvboxDarkBg0 = Color(0xFF282828) private val GruvboxDarkBg1 = Color(0xFF3C3836) private val GruvboxDarkBg2 = Color(0xFF504945) private val GruvboxDarkBg3 = Color(0xFF665C54) -private val GruvboxDarkBg4 = Color(0xFF7C6F64) private val GruvboxDarkFg1 = Color(0xFFEBDBB2) private val GruvboxDarkFg2 = Color(0xFFD5C4A1) private val GruvboxDarkFg3 = Color(0xFFBDAE93) -private val GruvboxGray = Color(0xFF928374) // same neutral gray in both modes // "Bright" accents — the variants gruvbox uses against a dark background. -private val GruvboxBrightRed = Color(0xFFFB4934) +// Derived role, not an upstream swatch: bright red/bg0 is only 4.29:1. +// Lighter red reaches 4.92:1 for error text and field labels on bg0. +private val GruvboxErrorText = Color(0xFFFF5F4F) private val GruvboxBrightGreen = Color(0xFFB8BB26) private val GruvboxBrightYellow = Color(0xFFFABD2F) private val GruvboxBrightBlue = Color(0xFF83A598) @@ -35,7 +37,6 @@ private val GruvboxLightBg0 = Color(0xFFFBF1C7) private val GruvboxLightBg1 = Color(0xFFEBDBB2) private val GruvboxLightBg2 = Color(0xFFD5C4A1) private val GruvboxLightBg3 = Color(0xFFBDAE93) -private val GruvboxLightBg4 = Color(0xFFA89984) private val GruvboxLightFg1 = Color(0xFF3C3836) private val GruvboxLightFg2 = Color(0xFF504945) private val GruvboxLightFg3 = Color(0xFF665C54) @@ -43,28 +44,38 @@ private val GruvboxLightFg3 = Color(0xFF665C54) // "Faded" accents — the muted variants gruvbox uses against a light // background, so contrast holds up without the neon dark-mode saturation. private val GruvboxFadedRed = Color(0xFF9D0006) + +// Derived roles, not upstream Gruvbox swatches: light bg0/faded green is 4.29:1; +// white/green reaches 4.86:1. Hard dark bg0/faded yellow is below 4.5:1; +// deeper neutral ink/yellow reaches 4.97:1. +private val GruvboxOnFadedGreen = Color(0xFFFFFFFF) +private val GruvboxOnFadedYellow = Color(0xFF121212) private val GruvboxFadedGreen = Color(0xFF79740E) private val GruvboxFadedYellow = Color(0xFFB57614) private val GruvboxFadedBlue = Color(0xFF076678) private val GruvboxFadedPurple = Color(0xFF8F3F71) private val GruvboxFadedOrange = Color(0xFFAF3A03) -/** Gruvbox theme — bright accents on dark bg0–bg4, faded accents on light bg0–bg4. */ +// Regression theme-contract: fixed roles reuse bright accents in both modes; +// Fixed/Dim share a swatch. Containers stop at bg3 for >= 4.5:1 body text. +// Upstream: https://github.com/morhetz/gruvbox/blob/master/colors/gruvbox.vim + +/** Gruvbox theme — bright accents on dark bg0–bg3, faded accents on light bg0–bg3. */ val GruvboxTheme = buildTheme( dark = PaletteColors( primary = GruvboxBrightOrange, onPrimary = GruvboxDarkBg0, - primaryContainer = GruvboxDarkBg4, + primaryContainer = GruvboxDarkBg2, onPrimaryContainer = GruvboxDarkFg1, secondary = GruvboxBrightBlue, onSecondary = GruvboxDarkBg0, - secondaryContainer = GruvboxDarkBg3, + secondaryContainer = GruvboxDarkBg2, onSecondaryContainer = GruvboxDarkFg2, tertiary = GruvboxBrightPurple, onTertiary = GruvboxDarkBg0, - tertiaryContainer = GruvboxDarkBg4, + tertiaryContainer = GruvboxDarkBg2, onTertiaryContainer = GruvboxDarkFg1, background = GruvboxDarkBg0, onBackground = GruvboxDarkFg1, @@ -72,15 +83,29 @@ val GruvboxTheme = onSurface = GruvboxDarkFg1, surfaceVariant = GruvboxDarkBg1, onSurfaceVariant = GruvboxDarkFg3, + surfaceDim = GruvboxDarkBg0, + surfaceBright = GruvboxDarkBg3, + primaryFixed = GruvboxBrightOrange, + primaryFixedDim = GruvboxBrightOrange, + onPrimaryFixed = GruvboxDarkBg0, + onPrimaryFixedVariant = GruvboxDarkBg0, + secondaryFixed = GruvboxBrightBlue, + secondaryFixedDim = GruvboxBrightBlue, + onSecondaryFixed = GruvboxDarkBg0, + onSecondaryFixedVariant = GruvboxDarkBg0, + tertiaryFixed = GruvboxBrightPurple, + tertiaryFixedDim = GruvboxBrightPurple, + onTertiaryFixed = GruvboxDarkBg0, + onTertiaryFixedVariant = GruvboxDarkBg0, surfaceContainerLowest = GruvboxDarkBg0, surfaceContainerLow = GruvboxDarkBg1, surfaceContainer = GruvboxDarkBg2, surfaceContainerHigh = GruvboxDarkBg3, - surfaceContainerHighest = GruvboxDarkBg4, + surfaceContainerHighest = GruvboxDarkBg3, inverseSurface = GruvboxDarkFg1, inverseOnSurface = GruvboxDarkBg0, inversePrimary = GruvboxFadedOrange, - outline = GruvboxGray, + outline = GruvboxDarkFg2, outlineVariant = GruvboxDarkBg3, scrim = GruvboxDarkBg0, status = @@ -91,9 +116,9 @@ val GruvboxTheme = warning = GruvboxBrightYellow, warningContainer = GruvboxDarkBg3, onWarning = GruvboxDarkBg0, - error = GruvboxBrightRed, + error = GruvboxErrorText, errorContainer = GruvboxDarkBg3, - onError = GruvboxDarkBg0, + onError = GruvboxDarkInk, onErrorContainer = GruvboxDarkFg1, info = GruvboxBrightBlue, infoContainer = GruvboxDarkBg3, @@ -104,15 +129,15 @@ val GruvboxTheme = PaletteColors( primary = GruvboxFadedOrange, onPrimary = GruvboxLightBg0, - primaryContainer = GruvboxLightBg4, + primaryContainer = GruvboxLightBg2, onPrimaryContainer = GruvboxLightFg1, secondary = GruvboxFadedBlue, onSecondary = GruvboxLightBg0, - secondaryContainer = GruvboxLightBg3, + secondaryContainer = GruvboxLightBg2, onSecondaryContainer = GruvboxLightFg2, tertiary = GruvboxFadedPurple, onTertiary = GruvboxLightBg0, - tertiaryContainer = GruvboxLightBg4, + tertiaryContainer = GruvboxLightBg2, onTertiaryContainer = GruvboxLightFg1, background = GruvboxLightBg0, onBackground = GruvboxLightFg1, @@ -120,29 +145,43 @@ val GruvboxTheme = onSurface = GruvboxLightFg1, surfaceVariant = GruvboxLightBg1, onSurfaceVariant = GruvboxLightFg3, + surfaceDim = GruvboxLightBg3, + surfaceBright = GruvboxLightBg0, + primaryFixed = GruvboxBrightOrange, + primaryFixedDim = GruvboxBrightOrange, + onPrimaryFixed = GruvboxDarkBg0, + onPrimaryFixedVariant = GruvboxDarkBg0, + secondaryFixed = GruvboxBrightBlue, + secondaryFixedDim = GruvboxBrightBlue, + onSecondaryFixed = GruvboxDarkBg0, + onSecondaryFixedVariant = GruvboxDarkBg0, + tertiaryFixed = GruvboxBrightPurple, + tertiaryFixedDim = GruvboxBrightPurple, + onTertiaryFixed = GruvboxDarkBg0, + onTertiaryFixedVariant = GruvboxDarkBg0, surfaceContainerLowest = GruvboxLightBg0, surfaceContainerLow = GruvboxLightBg1, surfaceContainer = GruvboxLightBg2, surfaceContainerHigh = GruvboxLightBg3, - surfaceContainerHighest = GruvboxLightBg4, + surfaceContainerHighest = GruvboxLightBg3, inverseSurface = GruvboxLightFg1, inverseOnSurface = GruvboxLightBg0, inversePrimary = GruvboxBrightOrange, - outline = GruvboxGray, + outline = GruvboxLightFg2, outlineVariant = GruvboxLightBg3, scrim = GruvboxLightBg0, status = HermesStatusColors( success = GruvboxFadedGreen, successContainer = GruvboxLightBg3, - onSuccess = GruvboxLightBg0, + onSuccess = GruvboxOnFadedGreen, warning = GruvboxFadedYellow, warningContainer = GruvboxLightBg3, - onWarning = GruvboxLightBg0, + onWarning = GruvboxOnFadedYellow, error = GruvboxFadedRed, errorContainer = GruvboxLightBg3, onError = GruvboxLightBg0, - onErrorContainer = GruvboxFadedRed, + onErrorContainer = GruvboxLightFg1, info = GruvboxFadedBlue, infoContainer = GruvboxLightBg3, onInfo = GruvboxLightBg0, diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/MonochromeScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/MonochromeScheme.kt index f4e1d13f..866a851b 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/MonochromeScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/MonochromeScheme.kt @@ -32,7 +32,6 @@ private val MonoLightBg = Color(0xFFFAFAFA) // background, surface, containerLow private val MonoLightContainer = Color(0xFFF0F0F0) // surfaceContainer private val MonoLightContainerHigh = Color(0xFFE0E0E0) // surfaceContainerHigh, surfaceVariant, status containers private val MonoLightContainerHighest = Color(0xFFD1D1D1) // surfaceContainerHighest, primaryContainer -private val MonoLightOutline = Color(0xFF808080) private val MonoLightOutlineVariant = Color(0xFFCCCCCC) private val MonoLightTextDim = Color(0xFF4D4D4D) // onSurfaceVariant private val MonoLightTextBright = Color(0xFF121212) // onBackground/onSurface/inverseSurface/primary/error @@ -71,6 +70,20 @@ val MonochromeTheme = onSurface = MonoDarkTextBright, surfaceVariant = MonoDarkContainerHigh, onSurfaceVariant = MonoDarkTextDim, + surfaceDim = MonoDarkBg, + surfaceBright = MonoDarkContainerHighest, + primaryFixed = MonoDarkTextBright, + primaryFixedDim = MonoDarkTextBright, + onPrimaryFixed = MonoDarkBg, + onPrimaryFixedVariant = MonoDarkBg, + secondaryFixed = MonoDarkSecondary, + secondaryFixedDim = MonoDarkSecondary, + onSecondaryFixed = MonoDarkBg, + onSecondaryFixedVariant = MonoDarkBg, + tertiaryFixed = MonoDarkTertiary, + tertiaryFixedDim = MonoDarkTertiary, + onTertiaryFixed = MonoDarkBg, + onTertiaryFixedVariant = MonoDarkBg, surfaceContainerLowest = MonoDarkFloor, surfaceContainerLow = MonoDarkBg, surfaceContainer = MonoDarkContainer, @@ -112,13 +125,27 @@ val MonochromeTheme = tertiary = MonoLightTertiary, onTertiary = MonoLightBg, tertiaryContainer = MonoLightContainerHigh, - onTertiaryContainer = MonoLightTertiary, + onTertiaryContainer = MonoLightSecondary, background = MonoLightBg, onBackground = MonoLightTextBright, surface = MonoLightBg, onSurface = MonoLightTextBright, surfaceVariant = MonoLightContainerHigh, onSurfaceVariant = MonoLightTextDim, + surfaceDim = MonoLightContainerHighest, + surfaceBright = MonoLightBg, + primaryFixed = MonoDarkTextBright, + primaryFixedDim = MonoDarkTextBright, + onPrimaryFixed = MonoDarkBg, + onPrimaryFixedVariant = MonoDarkBg, + secondaryFixed = MonoDarkSecondary, + secondaryFixedDim = MonoDarkSecondary, + onSecondaryFixed = MonoDarkBg, + onSecondaryFixedVariant = MonoDarkBg, + tertiaryFixed = MonoDarkTertiary, + tertiaryFixedDim = MonoDarkTertiary, + onTertiaryFixed = MonoDarkBg, + onTertiaryFixedVariant = MonoDarkBg, surfaceContainerLowest = MonoLightCeiling, surfaceContainerLow = MonoLightBg, surfaceContainer = MonoLightContainer, @@ -127,7 +154,7 @@ val MonochromeTheme = inverseSurface = MonoLightTextBright, inverseOnSurface = MonoLightBg, inversePrimary = MonoDarkTextBright, - outline = MonoLightOutline, + outline = MonoLightTertiary, outlineVariant = MonoLightOutlineVariant, scrim = Color.Black, status = @@ -144,7 +171,7 @@ val MonochromeTheme = onErrorContainer = MonoLightTextBright, info = MonoLightInfo, infoContainer = MonoLightContainerHigh, - onInfo = MonoLightBg, + onInfo = MonoLightTextBright, ), ), ) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/NordScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/NordScheme.kt index 599bdda1..b702227e 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/NordScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/NordScheme.kt @@ -11,9 +11,12 @@ import com.m57.hermescontrol.theme.buildTheme // supply the surfaces; Frost (7–10) and Aurora (11–15) supply the // accents. Unlike Gruvbox, Nord defines one accent set — not a // separate bright/faded pair per mode — so the same accent swatches -// are reused in both palettes below. +// are reused for fixed roles; primary/error need documented accessible tones. // --------------------------------------------------------------------- +// Derived role, not an upstream Nord swatch: nord0/red is 3.05:1 and +// nord0/purple is 4.41:1. Darker Polar Night ink meets 4.5:1 on both. +private val NordPolarInk = Color(0xFF0F1218) private val Nord0 = Color(0xFF2E3440) // Polar Night — darkest private val Nord1 = Color(0xFF3B4252) private val Nord2 = Color(0xFF434C5E) @@ -23,11 +26,23 @@ private val Nord5 = Color(0xFFE5E9F0) private val Nord6 = Color(0xFFECEFF4) // Snow Storm — lightest private val Nord8 = Color(0xFF88C0D0) // Frost — primary accent private val Nord9 = Color(0xFF81A1C1) // Frost — secondary / info -private val Nord11 = Color(0xFFBF616A) // Aurora red — error + +// Derived roles, not upstream Nord swatches. Nord10/Snow Storm is only 3.50:1; +// deeper Frost ink reaches 4.92:1 on nord6 for primary text/focus labels. +private val NordFrostInk = Color(0xFF486991) + +// Official Aurora red/nord0 is 3.05:1; a brighter red reaches 4.67:1. +private val NordErrorBright = Color(0xFFDE858F) + +// Official Aurora red/nord6 is 3.55:1; deeper red reaches 5.69:1. +private val NordErrorInk = Color(0xFFA53547) private val Nord13 = Color(0xFFEBCB8B) // Aurora yellow — warning private val Nord14 = Color(0xFFA3BE8C) // Aurora green — success private val Nord15 = Color(0xFFB48EAD) // Aurora purple — tertiary +// Fixed/Dim reuse each official accent in both modes (no invented tonal ladder). +// Upstream: https://www.nordtheme.com/docs/colors-and-palettes/ + /** * Nord theme. * @@ -36,12 +51,11 @@ private val Nord15 = Color(0xFFB48EAD) // Aurora purple — tertiary * tiers on each side share a value (surfaceContainerHigh == * surfaceContainerHighest) rather than inventing an off-palette hex. * - * Frost/Aurora accents are pastel by design, so onPrimary/onSecondary/ - * onTertiary/status "on" colors use Polar Night (dark text) in *both* - * modes — reusing Snow Storm for light-mode "on" text (the convention in + * Pastel Frost/Aurora fills use dark ink in both modes — reusing + * Snow Storm for light-mode "on" text (the convention in * the Catppuccin/Gruvbox presets) would fail contrast against colors like * Aurora yellow, which stays light even without a separate light-mode - * variant. + * variant. Light primary/error use Snow Storm ink on their darker fills. */ val NordTheme = buildTheme( @@ -56,7 +70,7 @@ val NordTheme = secondaryContainer = Nord2, onSecondaryContainer = Nord4, tertiary = Nord15, - onTertiary = Nord0, + onTertiary = NordPolarInk, tertiaryContainer = Nord3, onTertiaryContainer = Nord6, background = Nord0, @@ -65,6 +79,20 @@ val NordTheme = onSurface = Nord6, surfaceVariant = Nord1, onSurfaceVariant = Nord4, + surfaceDim = Nord0, + surfaceBright = Nord3, + primaryFixed = Nord8, + primaryFixedDim = Nord8, + onPrimaryFixed = Nord0, + onPrimaryFixedVariant = Nord0, + secondaryFixed = Nord9, + secondaryFixedDim = Nord9, + onSecondaryFixed = Nord0, + onSecondaryFixedVariant = Nord0, + tertiaryFixed = Nord15, + tertiaryFixedDim = Nord15, + onTertiaryFixed = NordPolarInk, + onTertiaryFixedVariant = NordPolarInk, surfaceContainerLowest = Nord0, surfaceContainerLow = Nord1, surfaceContainer = Nord2, @@ -72,8 +100,8 @@ val NordTheme = surfaceContainerHighest = Nord3, inverseSurface = Nord6, inverseOnSurface = Nord0, - inversePrimary = Nord8, - outline = Nord3, + inversePrimary = NordFrostInk, + outline = Nord4, outlineVariant = Nord2, scrim = Nord0, status = @@ -84,9 +112,9 @@ val NordTheme = warning = Nord13, warningContainer = Nord2, onWarning = Nord0, - error = Nord11, + error = NordErrorBright, errorContainer = Nord2, - onError = Nord0, + onError = NordPolarInk, onErrorContainer = Nord6, info = Nord9, infoContainer = Nord2, @@ -95,8 +123,8 @@ val NordTheme = ), light = PaletteColors( - primary = Nord8, - onPrimary = Nord0, + primary = NordFrostInk, + onPrimary = Nord6, primaryContainer = Nord4, onPrimaryContainer = Nord0, secondary = Nord9, @@ -104,7 +132,7 @@ val NordTheme = secondaryContainer = Nord5, onSecondaryContainer = Nord1, tertiary = Nord15, - onTertiary = Nord0, + onTertiary = NordPolarInk, tertiaryContainer = Nord4, onTertiaryContainer = Nord0, background = Nord6, @@ -113,6 +141,20 @@ val NordTheme = onSurface = Nord0, surfaceVariant = Nord5, onSurfaceVariant = Nord2, + surfaceDim = Nord4, + surfaceBright = Nord6, + primaryFixed = Nord8, + primaryFixedDim = Nord8, + onPrimaryFixed = Nord0, + onPrimaryFixedVariant = Nord0, + secondaryFixed = Nord9, + secondaryFixedDim = Nord9, + onSecondaryFixed = Nord0, + onSecondaryFixedVariant = Nord0, + tertiaryFixed = Nord15, + tertiaryFixedDim = Nord15, + onTertiaryFixed = NordPolarInk, + onTertiaryFixedVariant = NordPolarInk, surfaceContainerLowest = Nord6, surfaceContainerLow = Nord5, surfaceContainer = Nord5, @@ -132,10 +174,10 @@ val NordTheme = warning = Nord13, warningContainer = Nord5, onWarning = Nord0, - error = Nord11, + error = NordErrorInk, errorContainer = Nord5, - onError = Nord0, - onErrorContainer = Nord11, + onError = Nord6, + onErrorContainer = Nord0, info = Nord9, infoContainer = Nord5, onInfo = Nord0, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt index 5648a40a..0d18f6c0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing @@ -81,28 +82,8 @@ fun StatusBadge( val spacing = LocalSpacing.current val statusColors = LocalHermesStatusColors.current val (bgColor, fgColor) = - when (status) { - StatusBadgeType.SUCCESS -> { - statusColors.successContainer to statusColors.success - } - - StatusBadgeType.WARNING -> { - statusColors.warningContainer to statusColors.warning - } - - StatusBadgeType.ERROR -> { - statusColors.errorContainer to statusColors.error - } - - StatusBadgeType.INFO -> { - statusColors.infoContainer to statusColors.info - } - - StatusBadgeType.NEUTRAL -> { - MaterialTheme.colorScheme.surfaceContainerHigh to - MaterialTheme.colorScheme.onSurfaceVariant - } - } + statusBadgeColors(status, statusColors) + ?: (MaterialTheme.colorScheme.surfaceContainerHigh to MaterialTheme.colorScheme.onSurfaceVariant) Surface( modifier = modifier, shape = RoundedCornerShape(8.dp), @@ -135,6 +116,20 @@ fun StatusBadge( enum class StatusBadgeType { SUCCESS, WARNING, ERROR, INFO, NEUTRAL } +/** Exact fill/text pair shared by status badges and live-session indicators. */ +internal fun statusBadgeColors( + status: StatusBadgeType, + colors: HermesStatusColors, +): Pair? = + // PR #1417: on-status tokens belong on the status fill, not its container. + when (status) { + StatusBadgeType.SUCCESS -> colors.success to colors.onSuccess + StatusBadgeType.WARNING -> colors.warning to colors.onWarning + StatusBadgeType.ERROR -> colors.error to colors.onError + StatusBadgeType.INFO -> colors.info to colors.onInfo + StatusBadgeType.NEUTRAL -> null // Neutral badges use the active Material scheme. + } + // ── ToggleRow — switch row with label + description ──────────────────── @Composable diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt index 112493f2..7fad6475 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt @@ -25,6 +25,8 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.SessionLiveStatus import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.common.statusBadgeColors @Composable fun SessionLiveStatusIndicator( @@ -35,27 +37,26 @@ fun SessionLiveStatusIndicator( val spacing = LocalSpacing.current val statusColors = LocalHermesStatusColors.current - val (bgColor, fgColor, textRes, descRes) = + val (badgeType, textRes, descRes) = when (liveStatus) { SessionLiveStatus.WORKING -> { - Tuple4( - statusColors.successContainer, - statusColors.success, + Triple( + StatusBadgeType.SUCCESS, R.string.sessions_live_status_running, R.string.sessions_live_status_running_desc, ) } SessionLiveStatus.WAITING -> { - Tuple4( - statusColors.warningContainer, - statusColors.warning, + Triple( + StatusBadgeType.WARNING, R.string.sessions_live_status_waiting, R.string.sessions_live_status_waiting_desc, ) } } + val (bgColor, fgColor) = requireNotNull(statusBadgeColors(badgeType, statusColors)) val text = stringResource(textRes) val description = stringResource(descRes) @@ -103,10 +104,3 @@ fun SessionLiveStatusIndicator( } } } - -private data class Tuple4( - val first: A, - val second: B, - val third: C, - val fourth: D, -) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt index 1a8207f6..d17689db 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt @@ -14,7 +14,6 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -270,11 +269,6 @@ private fun ReleaseCandidateRow( checked = checked, onCheckedChange = onCheckedChange, enabled = enabled, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } } 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 e59f37a6..a3d9c37d 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 @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.settings.components +import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -21,7 +22,6 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -41,6 +41,7 @@ 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.theme.ThemeRegistry import com.m57.hermescontrol.ui.chat.label import com.m57.hermescontrol.ui.settings.SectionCard import kotlin.math.abs @@ -55,6 +56,7 @@ internal fun AppearanceSection( themePreset: ThemePreset, onThemePresetChange: (ThemePreset) -> Unit, ) { + val dynamicAvailable = useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S SectionCard { Text( text = stringResource(R.string.settings_item_theme), @@ -106,11 +108,6 @@ internal fun AppearanceSection( Switch( checked = useDynamicColors, onCheckedChange = onUseDynamicColorsChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } @@ -122,72 +119,30 @@ internal fun AppearanceSection( ) Spacer(modifier = Modifier.height(8.dp)) + if (dynamicAvailable) { + Text( + text = stringResource(R.string.settings_desc_theme_preset_dynamic), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + var presetsExpanded by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxWidth()) { OutlinedButton( onClick = { presetsExpanded = true }, - enabled = !useDynamicColors, modifier = Modifier.fillMaxWidth(), ) { - Text( - when (themePreset) { - ThemePreset.DEFAULT -> stringResource(R.string.theme_preset_default) - ThemePreset.MONOCHROME -> stringResource(R.string.theme_preset_monochrome) - ThemePreset.GRUVBOX -> stringResource(R.string.theme_preset_gruvbox) - ThemePreset.CATPPUCCIN -> stringResource(R.string.theme_preset_catppuccin) - ThemePreset.AMOLED -> stringResource(R.string.theme_preset_amoled) - ThemePreset.NORD -> stringResource(R.string.theme_preset_nord) - }, - ) + Text(themePreset.label()) } DropdownMenu( expanded = presetsExpanded, onDismissRequest = { presetsExpanded = false }, modifier = Modifier.fillMaxWidth(0.85f), ) { - ThemePreset.entries.forEach { preset -> + ThemeRegistry.forEach { (preset, _) -> DropdownMenuItem( - text = { - Text( - when (preset) { - ThemePreset.DEFAULT -> { - stringResource( - R.string.theme_preset_default, - ) - } - - ThemePreset.MONOCHROME -> { - stringResource( - R.string.theme_preset_monochrome, - ) - } - - ThemePreset.GRUVBOX -> { - stringResource( - R.string.theme_preset_gruvbox, - ) - } - - ThemePreset.CATPPUCCIN -> { - stringResource( - R.string.theme_preset_catppuccin, - ) - } - - ThemePreset.AMOLED -> { - stringResource( - R.string.theme_preset_amoled, - ) - } - - ThemePreset.NORD -> { - stringResource( - R.string.theme_preset_nord, - ) - } - }, - ) - }, + text = { Text(preset.label()) }, onClick = { onThemePresetChange(preset) presetsExpanded = false @@ -199,6 +154,19 @@ internal fun AppearanceSection( } } +@Composable +private fun ThemePreset.label(): String = + stringResource( + when (this) { + ThemePreset.DEFAULT -> R.string.theme_preset_default + ThemePreset.MONOCHROME -> R.string.theme_preset_monochrome + ThemePreset.GRUVBOX -> R.string.theme_preset_gruvbox + ThemePreset.CATPPUCCIN -> R.string.theme_preset_catppuccin + ThemePreset.AMOLED -> R.string.theme_preset_amoled + ThemePreset.NORD -> R.string.theme_preset_nord + }, + ) + @Composable internal fun ChatSection( typingEffectEnabled: Boolean, @@ -485,11 +453,6 @@ internal fun ChatSection( checked = showModelProvider, onCheckedChange = onShowModelProviderChange, modifier = Modifier.testTag("settings_show_model_provider"), - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } @@ -516,11 +479,6 @@ internal fun ChatSection( Switch( checked = typingEffectEnabled, onCheckedChange = onTypingEffectEnabledChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt index 7eca2657..fe8b38b0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -50,11 +49,6 @@ internal fun BehaviorSection( Switch( checked = autoReconnect, onCheckedChange = onAutoReconnectChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } @@ -83,11 +77,6 @@ internal fun BehaviorSection( Switch( checked = keepConnectedInBackground, onCheckedChange = onKeepConnectedInBackgroundChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } @@ -116,11 +105,6 @@ internal fun BehaviorSection( Switch( checked = restoreLastSession, onCheckedChange = onRestoreLastSessionChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00ed6cd8..f5916aa7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1845,4 +1845,5 @@ Catalog No catalog entries Installed + Used for status colors while Material You supplies the app colors. diff --git a/app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt b/app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt new file mode 100644 index 00000000..c2e5568c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt @@ -0,0 +1,64 @@ +package com.m57.hermescontrol.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Enabled-state contract for the pinned Material 3 defaults, verified again by the device gallery. */ +class ThemeComponentContrastTest { + @Test + fun everyResolvedPresetSupportsEnabledComponentStates() { + ThemeRegistry.forEach { (preset, _) -> + listOf(true, false).forEach { dark -> + val c = resolveColorScheme(preset, dark) + // PR #1417: these are component-state combinations, not just on-color pairs. + val graphics = + listOf( + "switch on thumb/track" to (c.onPrimary to c.primary), + "switch on track/surface" to (c.primary to c.surface), + "switch off thumb/track" to (c.outline to c.surfaceContainerHighest), + "switch off border/surface" to (c.outline to c.surface), + "radio selected/surface" to (c.primary to c.surface), + "radio unselected/surface" to (c.onSurfaceVariant to c.surface), + "checkbox selected/surface" to (c.primary to c.surface), + "checkbox checkmark/box" to (c.onPrimary to c.primary), + "checkbox unselected/surface" to (c.onSurfaceVariant to c.surface), + "slider active/inactive" to (c.primary to c.secondaryContainer), + "field normal border/surface" to (c.outline to c.surface), + "field focused border/surface" to (c.primary to c.surface), + "field error border/surface" to (c.error to c.surface), + "segment selected border/container" to (c.outline to c.secondaryContainer), + "segment unselected border/surface" to (c.outline to c.surface), + ) + val text = + listOf( + "primary foreground/surface" to (c.primary to c.surface), + "primary foreground/background" to (c.primary to c.background), + "error foreground/surface" to (c.error to c.surface), + "error foreground/background" to (c.error to c.background), + "field normal label/surface" to (c.onSurfaceVariant to c.surface), + "field focused label/surface" to (c.primary to c.surface), + "field error label/surface" to (c.error to c.surface), + "segment selected content/container" to (c.onSecondaryContainer to c.secondaryContainer), + "segment unselected content/surface" to (c.onSurface to c.surface), + ) + graphics.forEach { (name, pair) -> checkPair(preset, dark, name, pair, 3f) } + text.forEach { (name, pair) -> checkPair(preset, dark, name, pair, 4.5f) } + } + } + } + + private fun checkPair( + preset: ThemePreset, + dark: Boolean, + name: String, + pair: Pair, + minimum: Float, + ) { + val a = pair.first.luminance() + val b = pair.second.luminance() + val ratio = (maxOf(a, b) + 0.05f) / (minOf(a, b) + 0.05f) + assertTrue("$preset dark=$dark $name is $ratio, expected >= $minimum", ratio >= minimum) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt b/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt index 2228a910..e85dc2d9 100644 --- a/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt @@ -1,67 +1,164 @@ package com.m57.hermescontrol.theme +import androidx.compose.material3.ColorScheme import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import com.m57.hermescontrol.theme.presets.AmoledTheme -import com.m57.hermescontrol.theme.presets.CatppuccinTheme import com.m57.hermescontrol.theme.presets.DefaultTheme -import com.m57.hermescontrol.theme.presets.GruvboxTheme -import com.m57.hermescontrol.theme.presets.MonochromeTheme +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.common.statusBadgeColors import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test +import java.lang.reflect.Modifier /** * Guards the palette template's color invariants: - * 1. Every shipped error slot pair meets >= 3:1 WCAG contrast. + * 1. Normal-text pairs meet >= 4.5:1 contrast in every resolved mode. * 2. ThemeMode combinations are validated at construction time. - * 3. One-mode themes resolve and fall back to the default theme correctly. + * 3. Every Material role is explicitly mapped; fixed roles are mode-independent. + * 4. One-mode themes resolve and fall back to the default theme correctly. */ class ThemePaletteTest { - private val themes = - listOf( - ThemePreset.DEFAULT to DefaultTheme, - ThemePreset.MONOCHROME to MonochromeTheme, - ThemePreset.GRUVBOX to GruvboxTheme, - ThemePreset.CATPPUCCIN to CatppuccinTheme, - ThemePreset.AMOLED to AmoledTheme, - ) + @Test + fun registryCoversEveryPresetExactlyOnce() { + assertEquals(ThemePreset.entries, ThemeRegistry.map { it.preset }) + } @Test - fun errorSlotPairsMeetContrastInEveryShippedMode() { - themes.forEach { (preset, theme) -> + fun textSlotPairsMeetContrastInEveryResolvedMode() { + ThemeRegistry.forEach { (preset, _) -> listOf(true, false).forEach { dark -> - val scheme = theme.schemeFor(dark) ?: return@forEach - assertTrue( - "$preset dark=$dark onError/error contrast must be >= 3:1", - contrast(scheme.onError, scheme.error) >= 3f, - ) - assertTrue( - "$preset dark=$dark onErrorContainer/errorContainer contrast must be >= 3:1", - contrast(scheme.onErrorContainer, scheme.errorContainer) >= 3f, - ) + val c = resolveColorScheme(preset, dark) + val pairs = + listOf( + "onPrimary/primary" to (c.onPrimary to c.primary), + "onPrimaryContainer/primaryContainer" to (c.onPrimaryContainer to c.primaryContainer), + "onSecondary/secondary" to (c.onSecondary to c.secondary), + "onSecondaryContainer/secondaryContainer" to (c.onSecondaryContainer to c.secondaryContainer), + "onTertiary/tertiary" to (c.onTertiary to c.tertiary), + "onTertiaryContainer/tertiaryContainer" to (c.onTertiaryContainer to c.tertiaryContainer), + "onBackground/background" to (c.onBackground to c.background), + "onSurface/surface" to (c.onSurface to c.surface), + "onSurfaceVariant/surfaceVariant" to (c.onSurfaceVariant to c.surfaceVariant), + "onSurface/surfaceDim" to (c.onSurface to c.surfaceDim), + "onSurface/surfaceBright" to (c.onSurface to c.surfaceBright), + "onSurface/surfaceContainerLowest" to (c.onSurface to c.surfaceContainerLowest), + "onSurface/surfaceContainerLow" to (c.onSurface to c.surfaceContainerLow), + "onSurface/surfaceContainer" to (c.onSurface to c.surfaceContainer), + "onSurface/surfaceContainerHigh" to (c.onSurface to c.surfaceContainerHigh), + "onSurface/surfaceContainerHighest" to (c.onSurface to c.surfaceContainerHighest), + "inverseOnSurface/inverseSurface" to (c.inverseOnSurface to c.inverseSurface), + "onError/error" to (c.onError to c.error), + "onErrorContainer/errorContainer" to (c.onErrorContainer to c.errorContainer), + "onPrimaryFixed/primaryFixed" to (c.onPrimaryFixed to c.primaryFixed), + "onPrimaryFixedVariant/primaryFixed" to (c.onPrimaryFixedVariant to c.primaryFixed), + "onPrimaryFixed/primaryFixedDim" to (c.onPrimaryFixed to c.primaryFixedDim), + "onPrimaryFixedVariant/primaryFixedDim" to (c.onPrimaryFixedVariant to c.primaryFixedDim), + "onSecondaryFixed/secondaryFixed" to (c.onSecondaryFixed to c.secondaryFixed), + "onSecondaryFixedVariant/secondaryFixed" to (c.onSecondaryFixedVariant to c.secondaryFixed), + "onSecondaryFixed/secondaryFixedDim" to (c.onSecondaryFixed to c.secondaryFixedDim), + "onSecondaryFixedVariant/secondaryFixedDim" to + (c.onSecondaryFixedVariant to c.secondaryFixedDim), + "onTertiaryFixed/tertiaryFixed" to (c.onTertiaryFixed to c.tertiaryFixed), + "onTertiaryFixedVariant/tertiaryFixed" to (c.onTertiaryFixedVariant to c.tertiaryFixed), + "onTertiaryFixed/tertiaryFixedDim" to (c.onTertiaryFixed to c.tertiaryFixedDim), + "onTertiaryFixedVariant/tertiaryFixedDim" to (c.onTertiaryFixedVariant to c.tertiaryFixedDim), + ) + pairs.forEach { (name, colors) -> + val ratio = contrast(colors.first, colors.second) + assertTrue("$preset dark=$dark $name contrast $ratio must be >= 4.5:1", ratio >= 4.5f) + } } } } + @Test + fun renderedStatusBadgePairsMeetContrastInEveryResolvedMode() { + ThemeRegistry.forEach { (preset, _) -> + listOf(true, false).forEach { dark -> + val colors = resolveStatusColors(preset, dark) + StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL }.forEach { type -> + // PR #1417: test the mapping consumed by both reusable badge renderers. + val (background, foreground) = requireNotNull(statusBadgeColors(type, colors)) + val ratio = contrast(foreground, background) + assertTrue("$preset dark=$dark $type badge contrast $ratio must be >= 4.5:1", ratio >= 4.5f) + } + } + } + } + + @Test + fun statusBadgesUseMatchingStatusFillsAndOnColors() { + val c = dummyColors().status + assertEquals(c.success to c.onSuccess, statusBadgeColors(StatusBadgeType.SUCCESS, c)) + assertEquals(c.warning to c.onWarning, statusBadgeColors(StatusBadgeType.WARNING, c)) + assertEquals(c.error to c.onError, statusBadgeColors(StatusBadgeType.ERROR, c)) + assertEquals(c.info to c.onInfo, statusBadgeColors(StatusBadgeType.INFO, c)) + } + + @Test + fun fixedRolesDoNotChangeBetweenShippedModes() { + ThemeRegistry.forEach { (preset, theme) -> + val dark = theme.darkScheme + val light = theme.lightScheme + if (dark != null && light != null) { + val darkRoles = colorFields(dark).filterKeys { "Fixed" in it } + val lightRoles = colorFields(light).filterKeys { "Fixed" in it } + assertEquals("$preset fixed roles must be mode-independent", darkRoles, lightRoles) + } + } + } + + @Test + fun everyMaterialColorRoleIsMappedFromThePalette() { + // Distinct sentinel values catch swapped roles and accidental Material defaults. + val colors = dummyColors() + val expected = colorFields(colors).toMutableMap() + expected["surfaceTint"] = expected.getValue("primary") + expected.putAll( + colorFields(colors.status).filterKeys { + it in + setOf( + "error", + "onError", + "errorContainer", + "onErrorContainer", + ) + }, + ) + val theme = buildTheme(colors, colors) + listOf(theme.darkScheme, theme.lightScheme).forEach { scheme -> + assertEquals(expected, colorFields(requireNotNull(scheme))) + } + } + + private fun colorFields(value: Any): Map = + value.javaClass.declaredFields + .filter { + it.type == java.lang.Long.TYPE && + !Modifier.isStatic(it.modifiers) + }.associate { field -> + field.isAccessible = true + field.name to field.getLong(value) + } + /** * Full-bleed chat renderer gate (issue #866): agent prose renders directly * on the screen background (HermesScaffold containerColor = background), * so the full-bleed text pairs must hold against [ColorScheme.background]: * - body prose (onSurface) >= 4.5:1 — primary content, WCAG AA text * - header role label + timestamp (onSurfaceVariant) >= 3:1 — WCAG AA UI - * (The header deliberately avoids `primary`: Nord's light mode reuses its - * pastel Frost accent as primary, which cannot reach 3:1 on a light - * background.) - * Iterates ThemePreset.entries directly so EVERY shipped preset (incl. - * NORD, which the [themes] list above predates) is covered. + * Header text uses onSurfaceVariant independently of the theme's accent. + * The registry covers every shipped preset and its fallback mode. */ @Test fun fullBleedTextPairsMeetContrastInEveryShippedMode() { - ThemePreset.entries.forEach { preset -> + ThemeRegistry.forEach { (preset, _) -> listOf(true, false).forEach { dark -> - val scheme = resolveColorScheme(preset, darkTheme = dark) ?: return@forEach + val scheme = resolveColorScheme(preset, darkTheme = dark) assertTrue( "$preset dark=$dark onSurface/background contrast must be >= 4.5:1 (full-bleed prose)", contrast(scheme.onSurface, scheme.background) >= 4.5f, @@ -84,18 +181,18 @@ class ThemePaletteTest { @Test fun everyPresetResolvesItsDeclaredModes() { - themes.forEach { (preset, theme) -> + ThemeRegistry.forEach { (preset, theme) -> listOf(true, false).forEach { dark -> - if (theme.schemeFor(dark) != null) { - assertTrue( - "$preset dark=$dark scheme must resolve", - resolveColorScheme(preset, darkTheme = dark) != null, - ) - assertTrue( - "$preset dark=$dark status must resolve", - resolveStatusColors(preset, darkTheme = dark) != null, - ) - } + assertSame( + "$preset dark=$dark must resolve its palette or Default fallback", + theme.schemeFor(dark) ?: DefaultTheme.schemeFor(dark), + resolveColorScheme(preset, dark), + ) + assertSame( + "$preset dark=$dark must resolve its status or Default fallback", + theme.statusFor(dark) ?: DefaultTheme.statusFor(dark), + resolveStatusColors(preset, dark), + ) } } } @@ -169,50 +266,64 @@ class ThemePaletteTest { private fun dummyColors(): PaletteColors = PaletteColors( - primary = Color.White, - onPrimary = Color.Black, - primaryContainer = Color.White, - onPrimaryContainer = Color.Black, - secondary = Color.White, - onSecondary = Color.Black, - secondaryContainer = Color.White, - onSecondaryContainer = Color.Black, - tertiary = Color.White, - onTertiary = Color.Black, - tertiaryContainer = Color.White, - onTertiaryContainer = Color.Black, - background = Color.White, - onBackground = Color.Black, - surface = Color.White, - onSurface = Color.Black, - surfaceVariant = Color.White, - onSurfaceVariant = Color.Black, - surfaceContainerLowest = Color.White, - surfaceContainerLow = Color.White, - surfaceContainer = Color.White, - surfaceContainerHigh = Color.White, - surfaceContainerHighest = Color.White, - inverseSurface = Color.Black, - inverseOnSurface = Color.White, - inversePrimary = Color.Black, - outline = Color.Gray, - outlineVariant = Color.Gray, - scrim = Color.Black, + primary = Color(0xFF000000L + 1), + onPrimary = Color(0xFF000000L + 2), + primaryContainer = Color(0xFF000000L + 3), + onPrimaryContainer = Color(0xFF000000L + 4), + secondary = Color(0xFF000000L + 5), + onSecondary = Color(0xFF000000L + 6), + secondaryContainer = Color(0xFF000000L + 7), + onSecondaryContainer = Color(0xFF000000L + 8), + tertiary = Color(0xFF000000L + 9), + onTertiary = Color(0xFF000000L + 10), + tertiaryContainer = Color(0xFF000000L + 11), + onTertiaryContainer = Color(0xFF000000L + 12), + background = Color(0xFF000000L + 13), + onBackground = Color(0xFF000000L + 14), + surface = Color(0xFF000000L + 15), + onSurface = Color(0xFF000000L + 16), + surfaceVariant = Color(0xFF000000L + 17), + onSurfaceVariant = Color(0xFF000000L + 18), + surfaceDim = Color(0xFF000000L + 19), + surfaceBright = Color(0xFF000000L + 20), + primaryFixed = Color(0xFF000000L + 21), + primaryFixedDim = Color(0xFF000000L + 22), + onPrimaryFixed = Color(0xFF000000L + 23), + onPrimaryFixedVariant = Color(0xFF000000L + 24), + secondaryFixed = Color(0xFF000000L + 25), + secondaryFixedDim = Color(0xFF000000L + 26), + onSecondaryFixed = Color(0xFF000000L + 27), + onSecondaryFixedVariant = Color(0xFF000000L + 28), + tertiaryFixed = Color(0xFF000000L + 29), + tertiaryFixedDim = Color(0xFF000000L + 30), + onTertiaryFixed = Color(0xFF000000L + 31), + onTertiaryFixedVariant = Color(0xFF000000L + 32), + surfaceContainerLowest = Color(0xFF000000L + 33), + surfaceContainerLow = Color(0xFF000000L + 34), + surfaceContainer = Color(0xFF000000L + 35), + surfaceContainerHigh = Color(0xFF000000L + 36), + surfaceContainerHighest = Color(0xFF000000L + 37), + inverseSurface = Color(0xFF000000L + 38), + inverseOnSurface = Color(0xFF000000L + 39), + inversePrimary = Color(0xFF000000L + 40), + outline = Color(0xFF000000L + 41), + outlineVariant = Color(0xFF000000L + 42), + scrim = Color(0xFF000000L + 43), status = HermesStatusColors( - success = Color.Green, - successContainer = Color.White, - onSuccess = Color.Black, - warning = Color.Yellow, - warningContainer = Color.White, - onWarning = Color.Black, - error = Color.Red, - errorContainer = Color.White, - onError = Color.Black, - onErrorContainer = Color.Black, - info = Color.Blue, - infoContainer = Color.White, - onInfo = Color.Black, + success = Color(0xFF000000L + 100), + successContainer = Color(0xFF000000L + 101), + onSuccess = Color(0xFF000000L + 102), + warning = Color(0xFF000000L + 103), + warningContainer = Color(0xFF000000L + 104), + onWarning = Color(0xFF000000L + 105), + error = Color(0xFF000000L + 106), + errorContainer = Color(0xFF000000L + 107), + onError = Color(0xFF000000L + 108), + onErrorContainer = Color(0xFF000000L + 109), + info = Color(0xFF000000L + 110), + infoContainer = Color(0xFF000000L + 111), + onInfo = Color(0xFF000000L + 112), ), ) } From 7b6aed4bcdaf8f1faf5ab01c45c781250306c585 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:29:57 +0300 Subject: [PATCH 098/107] fix(chat): /compress no longer drops feedback on id-less transcript rows (#1419) --- .../hermescontrol/ui/chat/ChatViewModel.kt | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) 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 027ba385..1cb6e707 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 @@ -4270,21 +4270,28 @@ class ChatViewModel( val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId // 1. Authoritative transcript replacement + // Rows without a server id can't be keyed under newest-anchored paging; the mapper would throw and + // abort the whole result (feedback included). Fall back to a REST reload for those. + var needsReload = false if (response?.messages != null && sessionId != null) { - val replacementMessages = - withContext(historyDispatcher) { - mapServerMessages( - sessionId = sessionId, - messages = response.messages, - offset = 0, - latestPaging = latestPaging, - liveMessages = emptyList(), - activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()), - mediaUrl = ::gatewayMediaUrl, - ) - } - _uiState.update { it.copy(messages = replacementMessages) } - persistHistoryPage(replacementMessages, sessionId) + if (!latestPaging || response.messages.all { it.id != null }) { + val replacementMessages = + withContext(historyDispatcher) { + mapServerMessages( + sessionId = sessionId, + messages = response.messages, + offset = 0, + latestPaging = latestPaging, + liveMessages = emptyList(), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()), + mediaUrl = ::gatewayMediaUrl, + ) + } + _uiState.update { it.copy(messages = replacementMessages) } + persistHistoryPage(replacementMessages, sessionId) + } else { + needsReload = true + } } // 2. Display result summary via addAssistantMessage @@ -4300,6 +4307,12 @@ class ChatViewModel( } else { response?.message ?: "Context compressed." } + if (needsReload && sessionId != null) { + // Mirror handlePrefillResult: rehydrate from REST first, then post the feedback so it survives. + withContext(ioDispatcher) { repo.clearMessagesForSession(sessionId) } + _uiState.update { it.copy(messages = emptyList()) } + loadSessionMessages(sessionId, sessionGeneration) + } addAssistantMessage(feedback) // 3. Refresh usage & sessions From 26f259ddd8b01b1e4587f4b632d9a26eff654fea Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:30:18 +0300 Subject: [PATCH 099/107] feat(theme): add Garnet preset with deep red accents (#1418) --- DESIGN.md | 2 +- README.md | 2 +- .../com/m57/hermescontrol/theme/THEMES.md | 13 +- .../java/com/m57/hermescontrol/theme/Theme.kt | 2 +- .../m57/hermescontrol/theme/ThemeRegistry.kt | 2 + .../theme/presets/GarnetScheme.kt | 185 ++++++++++++++++++ .../settings/components/AppearanceSection.kt | 1 + app/src/main/res/values/strings.xml | 1 + 8 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/theme/presets/GarnetScheme.kt diff --git a/DESIGN.md b/DESIGN.md index 9908486b..376fb2ae 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -41,7 +41,7 @@ The visual language follows Material 3 guidelines strictly adapted for developer - **Core Principle**: Zero fluff. Fast render cycles, explicit states, flat surfaces, and zero visual ambiguity. ## Colors -The app supports 6 built-in presets (`Default/Slate`, `Monochrome`, `Gruvbox`, `Catppuccin`, `AMOLED`, `Nord`), plus optional Android 12+ wallpaper theming. `Theme.kt` selects the preset; `PaletteTemplate.kt` maps its colors to Material slots. AMOLED is dark-only; light mode falls back to Default. +The app supports 7 built-in presets (`Default/Slate`, `Monochrome`, `Gruvbox`, `Catppuccin`, `AMOLED`, `Nord`, `Garnet`), plus optional Android 12+ wallpaper theming. `Theme.kt` selects the preset; `PaletteTemplate.kt` maps its colors to Material slots. AMOLED is dark-only; light mode falls back to Default. 1. **Surfaces**: - `MaterialTheme.colorScheme.background`: Main canvas and full-bleed chat background. diff --git a/README.md b/README.md index 76b4aefe..10b40fc1 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi - **Gateway Status:** Monitor WebSocket connection, MCP servers, messaging channels, and OAuth providers. - **Productivity:** View and manage tasks via integrated Kanban boards, track agent milestones, and browse session history. - **Analytics & Billing:** Usage analytics dashboard and billing/subscription management. -- **Theming:** 6 built-in color presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord) plus Material You dynamic colors on supported devices. +- **Theming:** 7 built-in color presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, Garnet) plus Material You dynamic colors on supported devices. - **Modern UX:** Native Material 3 design with pull-to-refresh, scroll-aware TopBar, and customizable bottom navigation. --- diff --git a/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md b/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md index 12f57d82..dec68b80 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md +++ b/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md @@ -25,7 +25,8 @@ app/src/main/java/com/m57/hermescontrol/theme/ ├── GruvboxScheme.kt ├── CatppuccinScheme.kt ├── AmoledScheme.kt # dark-only (ThemeMode.DARK_ONLY) - └── NordScheme.kt + ├── NordScheme.kt + └── GarnetScheme.kt ``` ## The template — one shape for every theme @@ -95,7 +96,7 @@ Enabled Material components also have a component-state contract: `ThemeComponentContrastTest` checks the pinned Material role combinations in every resolved preset/mode. `ThemeComponentGalleryTest` reads the installed Compose -public color defaults and verifies rendered pixels for all twelve preset/mode +public color defaults and verifies rendered pixels for all fourteen preset/mode combinations, including focused/error fields. Disabled controls are deliberately excluded from the enabled contrast contract; Material uses opacity to mute them. Settings switches use Material defaults (`primary` track / `onPrimary` thumb), @@ -105,6 +106,14 @@ Outlines must contrast against both surface and the unchecked switch track (`surfaceContainerHighest`). Named palettes may need documented derived primary or error tones for direct text; fixed roles still preserve their upstream accents. +## Garnet + +Garnet preserves Default's slate surface ladder, typography, and semantic status +colors. Its seed is `#990000`, used as the light primary and dark primary +container. Dark primary/text/icons use a lighter `#FFB4AB` for contrast; rose +secondary and copper tertiary roles, their containers, fixed accents, inverse +primary, and surface tint follow the new accent family. Both modes are shipped. + ## Theme modes A theme declares which modes it ships via the factory you build it with: diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt index 7931c9ad..1bef1384 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt @@ -17,7 +17,7 @@ import kotlinx.serialization.Serializable enum class ThemePreference { SYSTEM, LIGHT, DARK } @Serializable -enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD } +enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD, GARNET } val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM } val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT } diff --git a/app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt b/app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt index 9d475bf8..20e1f5cb 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.theme import com.m57.hermescontrol.theme.presets.AmoledTheme import com.m57.hermescontrol.theme.presets.CatppuccinTheme import com.m57.hermescontrol.theme.presets.DefaultTheme +import com.m57.hermescontrol.theme.presets.GarnetTheme import com.m57.hermescontrol.theme.presets.GruvboxTheme import com.m57.hermescontrol.theme.presets.MonochromeTheme import com.m57.hermescontrol.theme.presets.NordTheme @@ -21,6 +22,7 @@ internal val ThemeRegistry = ThemeDefinition(ThemePreset.CATPPUCCIN, CatppuccinTheme), ThemeDefinition(ThemePreset.AMOLED, AmoledTheme), ThemeDefinition(ThemePreset.NORD, NordTheme), + ThemeDefinition(ThemePreset.GARNET, GarnetTheme), ) internal fun ThemePreset.palette(): ThemePalette = ThemeRegistry.single { it.preset == this }.palette diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/GarnetScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/GarnetScheme.kt new file mode 100644 index 00000000..b9b737a3 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/GarnetScheme.kt @@ -0,0 +1,185 @@ +package com.m57.hermescontrol.theme.presets + +import androidx.compose.ui.graphics.Color +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.PaletteColors +import com.m57.hermescontrol.theme.buildTheme + +// --------------------------------------------------------------------- +// Default slate surfaces retained; accents use the Garnet red family. +// Garnet Dark — named swatches +// --------------------------------------------------------------------- + +private val GarnetDarkBg0 = Color(0xFF0F1416) +private val GarnetDarkBg1 = Color(0xFF171C1E) +private val GarnetDarkBg2 = Color(0xFF1B2022) +private val GarnetDarkBg3 = Color(0xFF252B2D) +private val GarnetDarkBg4 = Color(0xFF303638) +private val GarnetDarkFg1 = Color(0xFFDEE3E5) +private val GarnetDarkFg3 = Color(0xFFBEC8CA) +private val GarnetGray = Color(0xFF889294) // same neutral gray in both modes + +// "Bright" accents — high-visibility variants against dark surfaces. +private val GarnetBrightCopper = Color(0xFFEABD96) +private val GarnetBrightRose = Color(0xFFE5BDB8) +private val GarnetBrightGreen = Color(0xFF81D692) +private val GarnetBrightYellow = Color(0xFFECC248) +private val GarnetBrightRed = Color(0xFFFFB4AB) +private val GarnetBrightInfo = Color(0xFF8ECEFF) + +// --------------------------------------------------------------------- +// Garnet Light — named swatches +// --------------------------------------------------------------------- + +private val GarnetLightBg0 = Color(0xFFF5FAFC) +private val GarnetLightBg1 = Color(0xFFEFF4F6) +private val GarnetLightBg2 = Color(0xFFE9EEF0) +private val GarnetLightBg3 = Color(0xFFE3E8EA) +private val GarnetLightBg4 = Color(0xFFDEE3E5) +private val GarnetLightFg1 = Color(0xFF171C1E) +private val GarnetLightFg3 = Color(0xFF3F484A) + +// "Faded" accents — muted variants for light background contrast. +private val GarnetAccent = Color(0xFF990000) +private val GarnetFadedCopper = Color(0xFF805531) +private val GarnetFadedRose = Color(0xFF775651) +private val GarnetFadedGreen = Color(0xFF126D2B) +private val GarnetFadedYellow = Color(0xFF725C00) +private val GarnetFadedRed = Color(0xFFBA1A1A) +private val GarnetFadedInfo = Color(0xFF006393) + +// #990000 is the seed and light primary. Its contrast on the dark canvas is only +// 2.08:1; #FFB4AB supplies readable dark-mode text/icons (10.93:1). +private val GarnetLightRedContainer = Color(0xFFFFDAD5) +private val GarnetDarkRoseContainer = Color(0xFF573935) +private val GarnetDarkCopperContainer = Color(0xFF634025) +private val GarnetLightCopperContainer = Color(0xFFFFDCC1) + +/** Garnet — Default slate surfaces with a #990000 red accent and matching warm tones. */ +val GarnetTheme = + buildTheme( + dark = + PaletteColors( + primary = GarnetBrightRed, + onPrimary = GarnetDarkBg0, + primaryContainer = GarnetAccent, + onPrimaryContainer = GarnetLightBg0, + secondary = GarnetBrightRose, + onSecondary = GarnetDarkBg0, + secondaryContainer = GarnetDarkRoseContainer, + onSecondaryContainer = GarnetBrightRose, + tertiary = GarnetBrightCopper, + onTertiary = GarnetDarkBg0, + tertiaryContainer = GarnetDarkCopperContainer, + onTertiaryContainer = GarnetBrightCopper, + background = GarnetDarkBg0, + onBackground = GarnetDarkFg1, + surface = GarnetDarkBg0, + onSurface = GarnetDarkFg1, + surfaceVariant = GarnetDarkBg1, + onSurfaceVariant = GarnetDarkFg3, + surfaceDim = GarnetDarkBg0, + surfaceBright = GarnetDarkBg4, + primaryFixed = GarnetBrightRed, + primaryFixedDim = GarnetBrightRed, + onPrimaryFixed = GarnetDarkBg0, + onPrimaryFixedVariant = GarnetDarkBg0, + secondaryFixed = GarnetBrightRose, + secondaryFixedDim = GarnetBrightRose, + onSecondaryFixed = GarnetDarkBg0, + onSecondaryFixedVariant = GarnetDarkBg0, + tertiaryFixed = GarnetBrightCopper, + tertiaryFixedDim = GarnetBrightCopper, + onTertiaryFixed = GarnetDarkBg0, + onTertiaryFixedVariant = GarnetDarkBg0, + surfaceContainerLowest = GarnetDarkBg0, + surfaceContainerLow = GarnetDarkBg1, + surfaceContainer = GarnetDarkBg2, + surfaceContainerHigh = GarnetDarkBg3, + surfaceContainerHighest = GarnetDarkBg4, + inverseSurface = GarnetDarkFg1, + inverseOnSurface = GarnetDarkBg0, + inversePrimary = GarnetAccent, + outline = GarnetGray, + outlineVariant = GarnetDarkBg3, + scrim = GarnetDarkBg0, + status = + HermesStatusColors( + success = GarnetBrightGreen, + successContainer = GarnetDarkBg3, + onSuccess = GarnetDarkBg0, + warning = GarnetBrightYellow, + warningContainer = GarnetDarkBg3, + onWarning = GarnetDarkBg0, + error = GarnetBrightRed, + errorContainer = GarnetDarkBg3, + onError = GarnetDarkBg0, + onErrorContainer = GarnetBrightRed, + info = GarnetBrightInfo, + infoContainer = GarnetDarkBg3, + onInfo = GarnetDarkBg0, + ), + ), + light = + PaletteColors( + primary = GarnetAccent, + onPrimary = GarnetLightBg0, + primaryContainer = GarnetLightRedContainer, + onPrimaryContainer = GarnetAccent, + secondary = GarnetFadedRose, + onSecondary = GarnetLightBg0, + secondaryContainer = GarnetLightRedContainer, + onSecondaryContainer = GarnetFadedRose, + tertiary = GarnetFadedCopper, + onTertiary = GarnetLightBg0, + tertiaryContainer = GarnetLightCopperContainer, + onTertiaryContainer = GarnetFadedCopper, + background = GarnetLightBg0, + onBackground = GarnetLightFg1, + surface = GarnetLightBg0, + onSurface = GarnetLightFg1, + surfaceVariant = GarnetLightBg1, + onSurfaceVariant = GarnetLightFg3, + surfaceDim = GarnetLightBg4, + surfaceBright = GarnetLightBg0, + primaryFixed = GarnetBrightRed, + primaryFixedDim = GarnetBrightRed, + onPrimaryFixed = GarnetDarkBg0, + onPrimaryFixedVariant = GarnetDarkBg0, + secondaryFixed = GarnetBrightRose, + secondaryFixedDim = GarnetBrightRose, + onSecondaryFixed = GarnetDarkBg0, + onSecondaryFixedVariant = GarnetDarkBg0, + tertiaryFixed = GarnetBrightCopper, + tertiaryFixedDim = GarnetBrightCopper, + onTertiaryFixed = GarnetDarkBg0, + onTertiaryFixedVariant = GarnetDarkBg0, + surfaceContainerLowest = GarnetLightBg0, + surfaceContainerLow = GarnetLightBg1, + surfaceContainer = GarnetLightBg2, + surfaceContainerHigh = GarnetLightBg3, + surfaceContainerHighest = GarnetLightBg4, + inverseSurface = GarnetLightFg1, + inverseOnSurface = GarnetLightBg0, + inversePrimary = GarnetBrightRed, + outline = GarnetLightFg3, + outlineVariant = GarnetLightBg3, + scrim = GarnetLightBg0, + status = + HermesStatusColors( + success = GarnetFadedGreen, + successContainer = GarnetLightBg3, + onSuccess = GarnetLightBg0, + warning = GarnetFadedYellow, + warningContainer = GarnetLightBg3, + onWarning = GarnetLightBg0, + error = GarnetFadedRed, + errorContainer = GarnetLightBg3, + onError = GarnetLightBg0, + onErrorContainer = GarnetFadedRed, + info = GarnetFadedInfo, + infoContainer = GarnetLightBg3, + onInfo = GarnetLightBg0, + ), + ), + ) 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 a3d9c37d..5b5ae0fa 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 @@ -164,6 +164,7 @@ private fun ThemePreset.label(): String = ThemePreset.CATPPUCCIN -> R.string.theme_preset_catppuccin ThemePreset.AMOLED -> R.string.theme_preset_amoled ThemePreset.NORD -> R.string.theme_preset_nord + ThemePreset.GARNET -> R.string.theme_preset_garnet }, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f5916aa7..2bca4211 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -365,6 +365,7 @@ Catppuccin AMOLED Black (Dark only) Nord + Garnet Language From a8dd0554262aca0441347cc627dad1baf0517f4b Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:45:06 +0300 Subject: [PATCH 100/107] feat(ui): copy button on error states and chat error snackbar (#1420) --- .../chat/components/ChatLifecycleEffects.kt | 24 ++++++++++++++++++- .../m57/hermescontrol/ui/common/StateViews.kt | 23 ++++++++++++++++++ app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 6 files changed, 50 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index e8cfd3bc..b6161f6d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -1,17 +1,23 @@ package com.m57.hermescontrol.ui.chat.components import android.Manifest +import android.content.ClipData import android.content.pm.PackageManager import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle @@ -19,6 +25,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import com.m57.hermescontrol.NavigationController +import com.m57.hermescontrol.R import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.notification.ReplyNotificationTracker import com.m57.hermescontrol.notification.correlationScopeId @@ -28,6 +35,7 @@ import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.SecretPromptUi import com.m57.hermescontrol.ui.chat.SudoPromptUi import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch @Composable fun ChatLifecycleEffects( @@ -137,10 +145,24 @@ fun ChatLifecycleEffects( } // Show error as snackbar + val clipboard = LocalClipboard.current + val copyLabel = context.getString(R.string.action_copy_error) + // Own scope + immediate clear: a later `errorMessage = null` elsewhere must not cancel a visible popup. + val snackbarScope = rememberCoroutineScope() LaunchedEffect(errorMessage) { errorMessage?.let { error -> - snackbarHostState.showSnackbar(error) viewModel.clearError() + snackbarScope.launch { + val result = + snackbarHostState.showSnackbar( + error, + actionLabel = copyLabel, + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, error))) + } + } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt index 4f9bd413..027bce0d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.common +import android.content.ClipData import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -22,6 +23,7 @@ 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.filled.ContentCopy import androidx.compose.material.icons.filled.Inbox import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button @@ -30,8 +32,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -39,6 +43,8 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -48,6 +54,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalSpacing +import kotlinx.coroutines.launch // Unified loading / error / empty / skeleton placeholders so every screen // shares the same visual language. @@ -96,6 +103,8 @@ fun ErrorState( modifier: Modifier = Modifier, ) { val spacing = LocalSpacing.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() Column( modifier = modifier @@ -117,6 +126,20 @@ fun ErrorState( textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, ) + TextButton( + onClick = { + scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message))) } + }, + modifier = Modifier.testTag("error_copy_button"), + ) { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.action_copy_error)) + } if (onRetry != null) { Spacer(modifier = Modifier.height(spacing.md)) Button( diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 6a328570..8e923ad0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -19,6 +19,7 @@ النتيجة التالية إغلاق البحث إعادة المحاولة +نسخ الخطأ إلغاء تأكيد مسح diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index fabaaf82..ee02ab7a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -22,6 +22,7 @@ 재시도 + 오류 복사 취소 지우기 확인 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 67868a7b..6944c427 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -19,6 +19,7 @@ 下一个匹配 关闭搜索 重试 + 复制错误 取消 确认 清除 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2bca4211..eb91aa7a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,6 +29,7 @@ Retry + Copy error Cancel Confirm Clear From 6e14c49df1233090304d6ceaa6843ad478aa02a2 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:16:32 +0300 Subject: [PATCH 101/107] fix(model): MoA row shows 'not loaded' on null temperatures (#1421) --- .../data/model/ModelAssignment.kt | 18 +++++++++--- .../chat/components/ChatLifecycleEffects.kt | 3 +- .../data/model/ModelSerializationTest.kt | 29 +++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ModelAssignment.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ModelAssignment.kt index ec25c2af..3a51c9b4 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ModelAssignment.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ModelAssignment.kt @@ -25,14 +25,20 @@ data class MainModelAssignment( data class MoaModelSlot( val provider: String, val model: String, + val reasoning_effort: String? = null, + val enabled: Boolean = true, ) @Serializable data class MoaConfigPreset( val reference_models: List, val aggregator: MoaModelSlot, - val reference_temperature: Double = 0.7, - val aggregator_temperature: Double = 0.3, + // null = omitted from API calls (provider default); the backend sends null when unset. + val reference_temperature: Double? = null, + val aggregator_temperature: Double? = null, + val reference_timeout: Double? = null, + val degraded_reference_policy: String = "loud", + val fanout: String? = null, val max_tokens: Int = 4096, val enabled: Boolean = true, ) @@ -44,8 +50,12 @@ data class MoaConfigResponse( val presets: Map = emptyMap(), val reference_models: List = emptyList(), val aggregator: MoaModelSlot = MoaModelSlot("", ""), - val reference_temperature: Double = 0.7, - val aggregator_temperature: Double = 0.3, + // null = omitted from API calls (provider default); the backend sends null when unset. + val reference_temperature: Double? = null, + val aggregator_temperature: Double? = null, + val reference_timeout: Double? = null, + val degraded_reference_policy: String = "loud", + val fanout: String? = null, val max_tokens: Int = 4096, val enabled: Boolean = true, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index b6161f6d..da4353e4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -146,7 +147,7 @@ fun ChatLifecycleEffects( // Show error as snackbar val clipboard = LocalClipboard.current - val copyLabel = context.getString(R.string.action_copy_error) + val copyLabel = stringResource(R.string.action_copy_error) // Own scope + immediate clear: a later `errorMessage = null` elsewhere must not cancel a visible popup. val snackbarScope = rememberCoroutineScope() LaunchedEffect(errorMessage) { diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt index 2fda9252..898e1dd4 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt @@ -622,4 +622,33 @@ class ModelSerializationTest { json.decodeFromString(jsonInput) } } + + @Test + fun moaConfigDecodesNullTemperaturesAndNewFields() { + val payload = + """ + {"default_preset":"default","active_preset":"","privacy_filter":"", + "presets":{"default":{"enabled":true, + "reference_models":[{"provider":"openrouter","model":"x/y","enabled":false,"reasoning_effort":"high"}], + "aggregator":{"provider":"openrouter","model":"a/b"}, + "reference_temperature":null,"aggregator_temperature":null, + "reference_timeout":null,"degraded_reference_policy":"silent","fanout":"every_n:3"}}, + "reference_models":[{"provider":"openrouter","model":"x/y","enabled":true}], + "aggregator":{"provider":"openrouter","model":"a/b"}, + "reference_temperature":null,"aggregator_temperature":null, + "reference_timeout":null,"degraded_reference_policy":"silent","fanout":"every_n:3","enabled":true} + """.trimIndent() + val cfg = json.decodeFromString(payload) + assertNull(cfg.reference_temperature) + assertNull(cfg.aggregator_temperature) + assertEquals("every_n:3", cfg.fanout) + val preset = cfg.presets.getValue("default") + assertEquals("silent", preset.degraded_reference_policy) + assertEquals(false, preset.reference_models.first().enabled) + assertEquals("high", preset.reference_models.first().reasoning_effort) + // Round-trip keeps the non-default knobs so a save doesn't reset them. + val back = json.encodeToString(cfg) + assertTrue(back.contains("every_n:3")) + assertTrue(back.contains("silent")) + } } From 0c336ecc4dee75be1d5a01fc09c16a11ee21df80 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:15:11 +0300 Subject: [PATCH 102/107] feat(chat): show agent message reactions (#1422) --- .../data/model/MessageReaction.kt | 33 +++++++++++ .../data/model/SessionMessagesResponse.kt | 4 ++ .../m57/hermescontrol/data/ws/EventParser.kt | 15 +++++ .../com/m57/hermescontrol/data/ws/WsEvent.kt | 12 ++++ .../m57/hermescontrol/ui/chat/ChatMessage.kt | 3 + .../ui/chat/ChatServerMessageMapper.kt | 1 + .../ui/chat/ChatTranscriptMerge.kt | 4 ++ .../ui/chat/ChatWsEventReducer.kt | 19 +++++++ .../chat/components/MessageReactionChips.kt | 44 ++++++++++++++ .../chat/fullbleed/FullBleedAgentMessage.kt | 5 ++ .../ui/chat/fullbleed/FullBleedChatList.kt | 10 ++++ .../hermescontrol/data/ws/EventParserTest.kt | 47 +++++++++++++++ .../chat/ChatServerMessageMapperPurityTest.kt | 35 ++++++++++++ .../ui/chat/ChatWsEventReducerTest.kt | 57 +++++++++++++++++++ 14 files changed, 289 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/MessageReaction.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageReactionChips.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/MessageReaction.kt b/app/src/main/java/com/m57/hermescontrol/data/model/MessageReaction.kt new file mode 100644 index 00000000..a95a6bb2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/MessageReaction.kt @@ -0,0 +1,33 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** One emoji reaction on a message. Tapback semantics: at most one per [author] per message. */ +data class MessageReaction( + val emoji: String, + val author: String, +) + +/** Reactions carried by a live `message.reaction` event payload (`reactions` list of maps). */ +internal fun parseMessageReactions(raw: Any?): List = + (raw as? List<*>).orEmpty().mapNotNull { item -> + val map = item as? Map<*, *> ?: return@mapNotNull null + val emoji = (map["emoji"] as? String)?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + MessageReaction(emoji = emoji, author = map["author"] as? String ?: "") + } + +/** Reactions persisted under `display_metadata.reactions` on a REST transcript row. */ +internal fun parseMessageReactions(displayMetadata: JsonElement?): List { + val list = (displayMetadata as? JsonObject)?.get("reactions") as? JsonArray ?: return emptyList() + return list.mapNotNull { item -> + val obj = item as? JsonObject ?: return@mapNotNull null + val emoji = + (obj["emoji"] as? JsonPrimitive)?.takeIf { it.isString }?.content?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + val author = (obj["author"] as? JsonPrimitive)?.takeIf { it.isString }?.content.orEmpty() + MessageReaction(emoji = emoji, author = author) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt index a5a5e1a1..1e0b6ca0 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt @@ -75,6 +75,10 @@ data class SessionMessage( val timestampText: String? get() = (timestamp as? JsonPrimitive)?.content + /** Emoji reactions persisted on this row (`display_metadata.reactions`). */ + val reactions: List + get() = parseMessageReactions(display_metadata) + val tokenCount: Int? get() { val prim = token_count as? JsonPrimitive ?: return null diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index 99639aa4..f4d56a93 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.data.ws import android.util.Log +import com.m57.hermescontrol.data.model.parseMessageReactions import com.m57.hermescontrol.ui.chat.extractTodosFromMap import kotlinx.serialization.json.JsonObject @@ -333,6 +334,20 @@ object EventParser { WsEvent.ReactionEvent(kind) } + "message.reaction" -> { + val rowId = (payload?.get("row_id") as? Number)?.toLong() + if (rowId == null) { + WsEvent.Unknown(rawJson) + } else { + WsEvent.MessageReactionUpdated( + rowId = rowId, + reactions = parseMessageReactions(payload["reactions"]), + role = payload["role"] as? String ?: "", + sessionId = sessionId, + ) + } + } + "approval.request" -> { val command = payload?.get("command") as? String val description = payload?.get("description") as? String diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index e5cf6c26..0f519032 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -445,6 +445,18 @@ sealed class WsEvent { val kind: String = "", ) : WsEvent() + /** + * `message.reaction`: the agent (or another client) set/cleared a tapback on a stored + * message. [reactions] is the row's full list after the write; [rowId] is the gateway + * SQLite row id, matched against `ChatMessage.serverRowId`. + */ + data class MessageReactionUpdated( + val rowId: Long, + val reactions: List, + val role: String, + val sessionId: String?, + ) : WsEvent() + // ── Replay resync (internal) ────────────────────────────────────────── /** Internal: replay could not cover the reconnect gap (truncated or epoch change) — UI must refetch history. */ 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 78fc4eaa..485cc931 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 @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.MessageReaction import java.util.UUID /** @@ -113,6 +114,8 @@ data class ChatMessage( * Scoped to the owning profile store; transient. */ val serverRowId: Long? = null, + /** Emoji tapbacks on this message (REST `display_metadata` or live `message.reaction`); transient. */ + val reactions: List = emptyList(), ) /** 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/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt index a620b5bd..af73df3f 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 @@ -274,6 +274,7 @@ internal fun mapServerMessages( tokenCount = tokenCount, completionId = completionId, serverRowId = msg.id?.toLong()?.takeIf { it > 0L }, + reactions = msg.reactions, ), ) } 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 f3f79c10..c6b38fbd 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 @@ -349,6 +349,7 @@ internal fun dedupeCachedMessages( message.copy( restId = it.canonicalRestId, serverRowId = message.serverRowId ?: it.serverRowId, + reactions = it.reactions.ifEmpty { message.reactions }, completionId = message.completionId ?: it.completionId, displayKind = message.displayKind ?: it.displayKind, isRestoredUnconfirmed = false, @@ -419,6 +420,7 @@ internal fun mergeCachedTranscriptPage( content = preservedContent ?: rich.content, restId = match.canonicalRestId ?: message.canonicalRestId, serverRowId = match.serverRowId ?: message.serverRowId, + reactions = message.reactions.ifEmpty { match.reactions }, completionId = match.completionId ?: message.completionId, displayKind = match.displayKind ?: message.displayKind, isRestoredUnconfirmed = @@ -457,6 +459,7 @@ internal fun mergeTranscriptWithLive( match.copy( restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id }, serverRowId = message.serverRowId ?: match.serverRowId, + reactions = message.reactions.ifEmpty { match.reactions }, displayKind = message.displayKind ?: match.displayKind, isHistoricalCache = false, isRestoredUnconfirmed = false, @@ -476,6 +479,7 @@ internal fun mergeTranscriptWithLive( match.copy( restId = message.canonicalRestId ?: match.canonicalRestId, serverRowId = message.serverRowId ?: match.serverRowId, + reactions = message.reactions.ifEmpty { match.reactions }, content = mergedContent, timestamp = message.timestamp, isStreaming = message.isStreaming, 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 9c2b5e04..65ecedfe 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 @@ -129,6 +129,7 @@ object ChatWsEventReducer { is WsEvent.BtwComplete -> event.sessionId is WsEvent.SessionUsage -> event.sessionId is WsEvent.TranscriptResyncRequired -> event.sessionId + is WsEvent.MessageReactionUpdated -> event.sessionId is WsEvent.VaultUnlockRequest -> event.sessionId is WsEvent.VaultUnlockExpire -> event.sessionId is WsEvent.VaultSaveLoginRequest -> event.sessionId @@ -252,6 +253,8 @@ object ChatWsEventReducer { // ReactionEvent is handled by the ViewModel — purely cosmetic animation is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState) + is WsEvent.MessageReactionUpdated -> onMessageReaction(state, streamingState, event) + // Change events (issue #784) are consumed by their screens' ViewModels is WsEvent.ChangeEvent -> ReducerResult(state = state, streamingState = streamingState) @@ -1079,6 +1082,22 @@ object ChatWsEventReducer { } } + /** Paints a live tapback on the row whose gateway id matches; unmatched rows arrive with the next REST load. */ + private fun onMessageReaction( + state: ChatUiState, + streamingState: StreamingState, + event: WsEvent.MessageReactionUpdated, + ): ReducerResult { + if (state.messages.none { it.serverRowId == event.rowId }) { + return ReducerResult(state = state, streamingState = streamingState) + } + val messages = + state.messages.map { + if (it.serverRowId == event.rowId) it.copy(reactions = event.reactions) else it + } + return ReducerResult(state = state.copy(messages = messages), streamingState = streamingState) + } + private fun onUnknown( state: ChatUiState, streamingState: StreamingState, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageReactionChips.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageReactionChips.kt new file mode 100644 index 00000000..b39c405e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageReactionChips.kt @@ -0,0 +1,44 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.data.model.MessageReaction + +/** + * Tapback chips shown under a message. One reaction per author, so a short row is enough; + * renders nothing when [reactions] is empty. + */ +@Composable +fun MessageReactionChips( + reactions: List, + modifier: Modifier = Modifier, +) { + if (reactions.isEmpty()) return + Row( + modifier = modifier.testTag("message_reactions"), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + reactions.forEach { reaction -> + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 0.dp, + ) { + Text( + text = reaction.emoji, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 3494f339..a95890a3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -41,6 +41,7 @@ import com.m57.hermescontrol.ui.chat.InlineAttachment import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.chat.MessageSegment import com.m57.hermescontrol.ui.chat.TokenEstimator +import com.m57.hermescontrol.ui.chat.components.MessageReactionChips import com.m57.hermescontrol.ui.chat.components.ReasoningCard import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback import com.m57.hermescontrol.ui.chat.splitByMedia @@ -143,6 +144,10 @@ internal fun FullBleedAgentMessage( } } + if (!message.isStreaming) { + MessageReactionChips(message.reactions, Modifier.padding(top = 2.dp)) + } + if (!message.isStreaming && message.content.isNotBlank()) { val showTokenStat = messageStatsEnabled && showAssistantMessageTokens && 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 126087a8..bf93897c 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 @@ -41,6 +41,7 @@ import com.m57.hermescontrol.ui.chat.UserBubble import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch import com.m57.hermescontrol.ui.chat.components.ChatScrollController import com.m57.hermescontrol.ui.chat.components.ClarifyBubble +import com.m57.hermescontrol.ui.chat.components.MessageReactionChips import com.m57.hermescontrol.ui.chat.components.ReasoningCard import com.m57.hermescontrol.ui.chat.components.VaultCodeCard import com.m57.hermescontrol.ui.chat.components.VaultSaveLoginCard @@ -311,6 +312,15 @@ fun FullBleedChatList( messageStatsEnabled = messageStatsEnabled, showUserMessageTokens = showUserMessageTokens, ) + if (userMessage.reactions.isNotEmpty()) { + MessageReactionChips( + reactions = userMessage.reactions, + modifier = + Modifier + .align(Alignment.End) + .padding(horizontal = 12.dp), + ) + } } } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index 892b9d69..08ad2389 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.ws +import com.m57.hermescontrol.data.model.MessageReaction import io.mockk.every import io.mockk.mockkStatic import io.mockk.unmockkAll @@ -928,6 +929,52 @@ class EventParserTest { ) } + @Test + fun testParseMessageReaction_returnsMessageReactionUpdated() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "message.reaction", + "session_id" to "s1", + "payload" to + mapOf( + "row_id" to 42, + "role" to "user", + "reactions" to + listOf( + mapOf("emoji" to "\uD83D\uDE02", "author" to "agent", "at" to 1.0), + mapOf("emoji" to "", "author" to "user"), + ), + ), + ), + ) + val event = EventParser.parse(response, "") as WsEvent.MessageReactionUpdated + assertEquals(42L, event.rowId) + assertEquals("user", event.role) + assertEquals("s1", event.sessionId) + assertEquals(listOf(MessageReaction("\uD83D\uDE02", "agent")), event.reactions) + } + + @Test + fun testParseMessageReaction_withoutRowId_isUnknown() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = mapOf("type" to "message.reaction", "payload" to mapOf("reactions" to emptyList())), + ) + assertTrue(EventParser.parse(response, "") is WsEvent.Unknown) + } + @Test fun testParseUnknownType_returnsUnknownEvent() { val response = diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt index 81feb1bf..baa54b7c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt @@ -1,8 +1,12 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.data.model.MessageReaction import com.m57.hermescontrol.data.model.SessionMessage import com.m57.hermescontrol.notification.ReplyNotificationTarget import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -59,4 +63,35 @@ class ChatServerMessageMapperPurityTest { assertEquals(42L, mapped.single().timestamp) } + + @Test + fun persistedReactionsHydrateFromDisplayMetadata() { + val row = + SessionMessage( + id = 5, + role = "user", + content = JsonPrimitive("hello"), + display_metadata = + buildJsonObject { + put( + "reactions", + buildJsonArray { + add( + buildJsonObject { + put("emoji", "\uD83D\uDE02") + put("author", "agent") + }, + ) + add(buildJsonObject { put("author", "user") }) + }, + ) + }, + ) + val mapped = mapServerMessages("s", listOf(row), 0, true, emptyList()).single() + assertEquals(listOf(MessageReaction("\uD83D\uDE02", "agent")), mapped.reactions) + assertEquals( + emptyList(), + mapServerMessages("s", listOf(assistant(6, "x")), 0, true, emptyList()).single().reactions, + ) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index fc25edfa..e3cbb3bb 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.data.model.MessageReaction import com.m57.hermescontrol.data.model.UsageSnapshotResponse import com.m57.hermescontrol.data.ws.WsEvent import org.junit.Assert.assertEquals @@ -1642,4 +1643,60 @@ class ChatWsEventReducerTest { assertEquals("idle", result.state.compressionStatus) assertEquals(initialMessages, result.state.messages) } + + @Test + fun messageReactionPaintsOnlyTheRowWithTheMatchingServerId() { + val target = ChatMessage(id = "a", role = MessageRole.USER, content = "hi", serverRowId = 7L) + val other = ChatMessage(id = "b", role = MessageRole.ASSISTANT, content = "yo", serverRowId = 8L) + val state = ChatUiState(currentSessionId = "s", messages = listOf(target, other)) + val reactions = listOf(MessageReaction("\u2764\uFE0F", "agent")) + val result = + ChatWsEventReducer.reduce( + state, + StreamingState(), + WsEvent.MessageReactionUpdated(7L, reactions, "user", "s"), + "s", + ) + assertEquals(reactions, result.state.messages[0].reactions) + assertTrue( + result.state.messages[1] + .reactions + .isEmpty(), + ) + val retracted = + ChatWsEventReducer.reduce( + result.state, + result.streamingState, + WsEvent.MessageReactionUpdated(7L, emptyList(), "user", "s"), + "s", + ) + assertTrue( + retracted.state.messages[0] + .reactions + .isEmpty(), + ) + } + + @Test + fun messageReactionForUnknownRowOrOtherSessionIsIgnored() { + val msg = ChatMessage(id = "a", role = MessageRole.USER, content = "hi", serverRowId = 7L) + val state = ChatUiState(currentSessionId = "s", messages = listOf(msg)) + val reactions = listOf(MessageReaction("\uD83D\uDC4D", "agent")) + val unknownRow = + ChatWsEventReducer.reduce( + state, + StreamingState(), + WsEvent.MessageReactionUpdated(99L, reactions, "user", "s"), + "s", + ) + assertEquals(state, unknownRow.state) + val otherSession = + ChatWsEventReducer.reduce( + state, + StreamingState(), + WsEvent.MessageReactionUpdated(7L, reactions, "user", "zzz"), + "s", + ) + assertEquals(state, otherSession.state) + } } From 5bf43e4a21a74d713f26d5ea6980c984a9274922 Mon Sep 17 00:00:00 2001 From: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:31:08 -0400 Subject: [PATCH 103/107] feat(notifications): optional alert when any session completes (#1401) Co-authored-by: M57 <41645758+Hy4ri@users.noreply.github.com> --- .../data/config/ServerStoreState.kt | 3 + .../hermescontrol/data/local/AuthManager.kt | 6 + .../data/model/SessionLiveStatus.kt | 1 + .../BackgroundConnectionController.kt | 1 + .../BackgroundConnectionPolicy.kt | 15 +- .../notification/ChatNotificationService.kt | 251 +++++++++++++++--- .../ui/settings/SettingsSubPages.kt | 2 + .../ui/settings/SettingsViewModel.kt | 8 + .../ui/settings/components/BehaviorSection.kt | 61 +++++ app/src/main/res/values/strings.xml | 4 + .../BackgroundConnectionControllerTest.kt | 4 + .../BackgroundConnectionPolicyTest.kt | 42 +++ .../ChatNotificationServiceTest.kt | 114 ++++++++ .../ui/settings/SettingsViewModelTest.kt | 2 + 14 files changed, 474 insertions(+), 40 deletions(-) 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 e81a38cb..8e4360b2 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 @@ -32,6 +32,9 @@ data class ServerStoreState( val showTokensPerSecond: Boolean = true, val showModelProvider: Boolean = false, val keepConnectedInBackground: Boolean = false, + // Notify when ANY session finishes while the app is backgrounded. Needs a + // live background connection, so it implies the keep-connected lease. + val notifySessionCompletions: Boolean = false, // Opt-in update channel: when true the in-app update check also considers // pre-release release-candidate GitHub releases, not just stable ones. val checkReleaseCandidateUpdates: 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 464b130c..d621cbb4 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 @@ -871,6 +871,12 @@ object AuthManager { serverStore.update { it.copy(keepConnectedInBackground = enabled) } } + fun isNotifySessionCompletions(): Boolean = serverStore.getLatestState().notifySessionCompletions + + fun setNotifySessionCompletions(enabled: Boolean) { + serverStore.update { it.copy(notifySessionCompletions = enabled) } + } + // ── Chat Font Scale (issue #1004) ─────────────────────────────────── fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt index 3f201350..bf827456 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt @@ -30,6 +30,7 @@ data class ActiveSessionItem( val status: String? = null, @SerialName("last_active") val lastActive: Double? = null, + val title: String? = null, ) /** diff --git a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt index 4060e569..eec6e688 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt @@ -51,6 +51,7 @@ class BackgroundConnectionController( status = status, isAutoReconnect = AuthManager.isAutoReconnect(), hasActiveNetwork = NetworkMonitor.isConnected.value, + notifyCompletionsOptIn = AuthManager.isNotifySessionCompletions(), ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt index 693108ad..216ca3da 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt @@ -14,6 +14,11 @@ data class BackgroundConnectionSnapshot( val status: ConnectionStatus, val isAutoReconnect: Boolean = true, val hasActiveNetwork: Boolean = true, + /** + * User opted into "notify when a session completes" — completions arrive + * only over a live connection, so this implies the keep-connected lease. + */ + val notifyCompletionsOptIn: Boolean = false, ) { companion object { fun ineligible( @@ -77,8 +82,12 @@ object BackgroundConnectionPolicy { ) } - // Demand check: need either a pending reply or persistent opt-in - val hasDemand = snapshot.pendingReply || snapshot.keepConnectedOptIn + // Demand check: need a pending reply or a persistent opt-in. The + // completions notify opt-in implies the keep-connected lease — a + // session can only be observed finishing over a live connection. + val notifyCompletionsDemand = snapshot.notifyCompletionsOptIn + val keepConnectedLease = snapshot.keepConnectedOptIn || notifyCompletionsDemand + val hasDemand = snapshot.pendingReply || keepConnectedLease if (!hasDemand) { return BackgroundConnectionDecision( shouldHoldService = false, @@ -134,7 +143,7 @@ object BackgroundConnectionPolicy { return BackgroundConnectionDecision( shouldHoldService = true, - shouldHoldPersistentLease = snapshot.keepConnectedOptIn, + shouldHoldPersistentLease = keepConnectedLease, notificationState = notificationState, ) } 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 6b1668ec..0f3ac200 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -9,16 +9,21 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.IBinder +import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.RemoteInput import com.m57.hermescontrol.MainActivity import com.m57.hermescontrol.R import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.ActiveSessionsResponse 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.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionActiveListParams import com.m57.hermescontrol.ui.chat.replyFailureFromPayload +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -27,6 +32,9 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.serializer import java.util.concurrent.atomic.AtomicBoolean internal data class MessageCompleteNotificationPlan( @@ -38,6 +46,87 @@ internal data class MessageCompleteNotificationPlan( val allowInlineReply: Boolean, ) +internal data class CompletedSessionRef( + val storedId: String, + val title: String?, +) + +private const val TAG = "ChatNotificationService" + +/** Short cap so a slow `session.active_list` cannot hold a completion alert hostage. */ +private const val ACTIVE_LIST_TIMEOUT_MS = 5_000L + +/** Where a background `message.complete` goes. */ +internal sealed interface MessageCompleteRoute { + /** The reply flow: reply notification, then the foreground service is retired. */ + data object Reply : MessageCompleteRoute + + /** A session the phone did not submit finished; the opt-in alert fires. */ + data object ForeignSession : MessageCompleteRoute +} + +/** + * Route a completion the way the service did before the opt-in existed: + * toggle OFF must keep the legacy behavior exactly — every completion uses + * the reply path, even when `ActiveSessionHolder` was cleared by a background + * reconnect (dropping the completion would both hide the reply notification + * and strand the foreground service). + */ +internal fun messageCompleteRoute( + holderSessionId: String?, + toggleOn: Boolean, + eventSessionId: String?, +): MessageCompleteRoute = + if (!toggleOn || (!eventSessionId.isNullOrBlank() && eventSessionId == holderSessionId)) { + MessageCompleteRoute.Reply + } else { + MessageCompleteRoute.ForeignSession + } + +/** + * Map a WS event's runtime session id to the STORED session row the app can + * open. The gateway emits `message.complete` with its ephemeral transport id + * (`uuid4().hex[:8]`), while History/`switchSession` use the persisted + * `session_key` — a runtime id taps into a blank chat. `session.active_list` + * lists every live session (idle included) with both ids plus the title. + * + * Accepts the typed RPC result (`JsonElement`) and its pre-JSON `Map` shape, + * mirroring `SessionLiveStatusDecoder.decodeResponse`. + */ +internal fun parseActiveSessionLookup( + raw: Any?, + runtimeId: String, +): CompletedSessionRef? { + if (runtimeId.isBlank()) return null + val element = + when (raw) { + is JsonObject -> { + raw + } + + is String -> { + runCatching { lookupJson.parseToJsonElement(raw) }.getOrNull() ?: return null + } + + else -> { + return null + } + } + val response = + runCatching { + lookupJson.decodeFromJsonElement(serializer(), element) + }.getOrNull() ?: return null + for (item in response.sessions) { + if (item.id?.trim() != runtimeId) continue + val storedId = item.sessionKey?.trim().orEmpty() + if (storedId.isBlank()) return null + return CompletedSessionRef(storedId, item.title?.trim()?.takeIf { it.isNotEmpty() }) + } + return null +} + +private val lookupJson = Json { ignoreUnknownKeys = true } + internal fun messageCompleteNotificationPlan( event: WsEvent.MessageComplete, targetSessionId: String?, @@ -107,6 +196,9 @@ class ChatNotificationService : Service() { internal const val NOTIFICATION_ID = 1 internal const val PENDING_NOTIFICATION_ID = 2 + /** Session-complete notifications: one fixed id, tagged per stored session. */ + private const val SESSION_COMPLETE_NOTIFICATION_ID = 10_000 + private val isAppInForeground = AtomicBoolean(false) internal val lifecycle = ForegroundServiceLifecycle() @@ -153,43 +245,99 @@ class ChatNotificationService : Service() { if (!isAppInForeground.get()) { when (event) { is WsEvent.MessageComplete -> { - 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 = 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 - // normal, safe outcome: the notification is then - // never auto-dismissed from REST hydration, which - // is strictly better than dismissing the wrong - // duplicate reply. - serverMessageId = - plan.correlationText?.let { - coalesceTurnRow(plan.sessionId, it) - }, - ) - // The wait is over — retire the foreground - // service. The reply notification above - // replaces the persistent "waiting" one, - // and the pendingReply flag is cleared by - // HermesWsClient's own collector, so the - // service is not restarted on the next - // ON_STOP (issue #794). - // A delayed completion must not retire a newer turn/start. - BackgroundConnectionController.default.onReplyCompleted(generation) + when ( + val route = + messageCompleteRoute( + holderSessionId = ActiveSessionHolder.activeSessionId.value, + toggleOn = AuthManager.isNotifySessionCompletions(), + eventSessionId = event.sessionId, + ) + ) { + MessageCompleteRoute.Reply -> { + 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 = 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 + // normal, safe outcome: the notification is then + // never auto-dismissed from REST hydration. + serverMessageId = + plan.correlationText?.let { + coalesceTurnRow(plan.sessionId, it) + }, + ) + // The wait is over: retire the foreground service + // (issue #794). A delayed completion must not + // retire a newer turn/start. + BackgroundConnectionController.default.onReplyCompleted(generation) + } + + MessageCompleteRoute.ForeignSession -> { + // Another session finished. Resolve its runtime + // id to the STORED session row (the gateway's + // transport id means nothing to History/chat); + // without the stored id the tap would open a + // blank chat, so skip instead. The notification + // is plain — no inline reply, no reply-tracker + // registration — and the service keeps listening + // for the next completion (the opt-in demand + // holds it). + val ref = + try { + parseActiveSessionLookup( + HermesWsClient.call( + RpcMethods.SESSION_ACTIVE_LIST, + SessionActiveListParams(), + timeoutMs = ACTIVE_LIST_TIMEOUT_MS, + ), + event.sessionId.orEmpty(), + ) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + if (ref != null) { + // A failed turn must not read as success. + val failure = + replyFailureFromPayload( + event.rawPayload, + fallback = "Unknown gateway error", + ) + val snippet = + (failure?.details ?: event.text) + .take(100) + .replace("\n", " ") + .ifBlank { getString(R.string.notif_new_message) } + val body = + if (failure != null) { + getString(R.string.notif_session_failed, snippet) + } else { + getString(R.string.notif_session_complete, snippet) + } + showSessionCompleteNotification(ref, body) + } else { + Log.w( + TAG, + "Session-complete notification skipped: no stored id for runtime ${event.sessionId}", + ) + } + } + } } is WsEvent.ClarifyRequest -> { @@ -365,6 +513,35 @@ class ChatNotificationService : Service() { } } + /** + * A completion from a session the phone did not submit (cron, another + * client). Fires only when "notify on session completion" is on, with the + * session's STORED id (tap opens the real transcript) and its title when + * the gateway reported one. Plain alert, auto-cancel; the notification + * uses the tag overload — per-session tag with one shared id — so tags + * never collide and no session's alert can clobber a live reply + * notification or tombstone the tracker. + */ + private fun showSessionCompleteNotification( + ref: CompletedSessionRef, + body: String, + ) { + val notification = + NotificationCompat + .Builder(this, CHAT_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(ref.title ?: getString(R.string.notif_title)) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setAutoCancel(true) + .setContentIntent(buildContentIntent(ref.storedId)) + .build() + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return + manager.notify(ref.storedId, SESSION_COMPLETE_NOTIFICATION_ID, notification) + } + private fun buildContentIntent(sessionId: String?): PendingIntent { val intent = Intent(this, MainActivity::class.java).apply { 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 174605b6..84352316 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 @@ -241,6 +241,8 @@ internal fun SettingsBehaviorPage( onAutoReconnectChange = viewModel::onAutoReconnectChange, keepConnectedInBackground = state.keepConnectedInBackground, onKeepConnectedInBackgroundChange = viewModel::onKeepConnectedInBackgroundChange, + notifySessionCompletions = state.notifySessionCompletions, + onNotifySessionCompletionsChange = viewModel::onNotifySessionCompletionsChange, restoreLastSession = state.restoreLastSession, onRestoreLastSessionChange = viewModel::onRestoreLastSessionChange, ) 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 e2ebd227..1c1f3ef5 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 @@ -47,6 +47,7 @@ data class SettingsUiState( val showTokensPerSecond: Boolean = true, val showModelProvider: Boolean = false, val keepConnectedInBackground: Boolean = false, + val notifySessionCompletions: Boolean = false, val profiles: List = emptyList(), val selectedProfileId: String? = null, val renameProfileName: String = "", @@ -96,6 +97,7 @@ class SettingsViewModel( val showTokensPerSecond = AuthManager.isTokensPerSecondEnabled() val showModelProvider = AuthManager.isModelProviderShown() val keepConnectedInBackground = AuthManager.isKeepConnectedInBackground() + val notifySessionCompletions = AuthManager.isNotifySessionCompletions() val profiles = AuthManager.getConnectionProfiles() val appLanguage = AuthManager.getAppLanguage() val renameProfileName = @@ -124,6 +126,7 @@ class SettingsViewModel( showTokensPerSecond = showTokensPerSecond, showModelProvider = showModelProvider, keepConnectedInBackground = keepConnectedInBackground, + notifySessionCompletions = notifySessionCompletions, profiles = profiles, selectedProfileId = selectedId, renameProfileName = renameProfileName, @@ -394,6 +397,11 @@ class SettingsViewModel( AuthManager.setKeepConnectedInBackground(enabled) } + fun onNotifySessionCompletionsChange(enabled: Boolean) { + _uiState.update { it.copy(notifySessionCompletions = enabled) } + AuthManager.setNotifySessionCompletions(enabled) + } + /** Reconcile the chat shortcut with changes made through Settings or another screen. */ fun refreshKeepConnectedInBackground() { val enabled = AuthManager.isKeepConnectedInBackground() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt index fe8b38b0..00c8e92a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt @@ -1,5 +1,10 @@ package com.m57.hermescontrol.ui.settings.components +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,8 +18,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import com.m57.hermescontrol.R import com.m57.hermescontrol.ui.settings.SectionCard @@ -24,9 +31,20 @@ internal fun BehaviorSection( onAutoReconnectChange: (Boolean) -> Unit, keepConnectedInBackground: Boolean, onKeepConnectedInBackgroundChange: (Boolean) -> Unit, + notifySessionCompletions: Boolean, + onNotifySessionCompletionsChange: (Boolean) -> Unit, restoreLastSession: Boolean, onRestoreLastSessionChange: (Boolean) -> Unit, ) { + val context = LocalContext.current + // Turning session-complete alerts on needs POST_NOTIFICATIONS (Android + // 13+). The chat screen requests it on open, but the toggle lives in + // Settings — gate here so enabling never lands in a state where the OS + // silently blocks what the user just asked for. Denial reverts the toggle. + val permissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) onNotifySessionCompletionsChange(true) + } SectionCard { Row( modifier = Modifier.fillMaxWidth(), @@ -84,6 +102,49 @@ internal fun BehaviorSection( HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_notify_session_completions_title), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_notify_session_completions_desc), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = notifySessionCompletions, + onCheckedChange = { enabled -> + if (!enabled) { + onNotifySessionCompletionsChange(false) + } else { + val permission = Manifest.permission.POST_NOTIFICATIONS + val granted = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, permission) == + PackageManager.PERMISSION_GRANTED + if (granted) { + onNotifySessionCompletionsChange(true) + } else { + permissionLauncher.launch(permission) + } + } + }, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(12.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb91aa7a..213cfc57 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -165,6 +165,8 @@ Keeps connection alive in the background Hermes Chat Replies Alerts for new replies from Hermes + Session finished: %1$s + Session failed: %1$s Reply @@ -270,6 +272,8 @@ Auth Token Stay connected in background Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery. + Notify when a session completes + Alert when any Hermes session finishes while the app is in the background. Keeps Hermes connected in the background (ongoing notification, extra battery use) so finished sessions can alert you. Behavior Auto-Reconnect Automatically reconnect on disconnect diff --git a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt index 209cb199..abd1ca1c 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt @@ -287,6 +287,7 @@ class BackgroundConnectionControllerTest { every { AuthManager.isGatedMode() } returns true every { AuthManager.getToken() } returns null every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isNotifySessionCompletions() } returns false every { AuthManager.isAutoReconnect() } returns true every { ChatNotificationService.isAppInForeground() } returns false every { HermesWsClient.pendingReply } returns false @@ -320,6 +321,7 @@ class BackgroundConnectionControllerTest { every { AuthManager.isGatedMode() } returns false every { AuthManager.getToken() } returns null every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isNotifySessionCompletions() } returns false every { AuthManager.isAutoReconnect() } returns true every { ChatNotificationService.isAppInForeground() } returns false every { HermesWsClient.pendingReply } returns false @@ -352,6 +354,7 @@ class BackgroundConnectionControllerTest { every { AuthManager.isGatedMode() } returns false every { AuthManager.getToken() } returns "some-token" every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isNotifySessionCompletions() } returns false every { AuthManager.isAutoReconnect() } returns true every { ChatNotificationService.isAppInForeground() } returns false every { HermesWsClient.pendingReply } returns false @@ -385,6 +388,7 @@ class BackgroundConnectionControllerTest { every { AuthManager.isGatedMode() } returns false every { AuthManager.getToken() } returns "some-token" every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isNotifySessionCompletions() } returns false every { AuthManager.isAutoReconnect() } returns false every { ChatNotificationService.isAppInForeground() } returns false every { HermesWsClient.pendingReply } returns false diff --git a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt index fda85801..c0bbc81a 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt @@ -250,4 +250,46 @@ class BackgroundConnectionPolicyTest { assertTrue(decision.shouldHoldPersistentLease) assertEquals(BackgroundNotificationState.Connecting, decision.notificationState) } + + // ── Session-completion notify opt-in (implies the keep-connected lease) ── + + @Test + fun testBackground_notifyCompletionsOn_alone_holdsServiceAndLease() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + notifyCompletionsOptIn = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertTrue(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.ConnectedInBackground, decision.notificationState) + } + + @Test + fun testBackground_notifyCompletionsOff_pendingReplyOnly_holdsServiceWithoutLease() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + notifyCompletionsOptIn = false, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.WaitingForReplies, decision.notificationState) + } } 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 71e116e7..225a33c1 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.notification import android.content.Context +import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.WsEvent import io.mockk.mockk import io.mockk.verify @@ -164,3 +165,116 @@ class ChatNotificationServiceTest { assertEquals("Answer\ncontinued", plan.correlationText) } } + +class ChatNotificationServiceActiveListTest { + @Test + fun `parseActiveSessionLookup decodes the typed JSON result and maps ids plus title`() { + val raw = + """ + {"sessions":[ + {"id":"rt1","session_key":"stored-1","title":"Nightly report","status":"idle","last_active":1780000000.0}, + {"id":"rt2","session_key":"stored-2","status":"idle"} + ]} + """.trimIndent() + + val found = parseActiveSessionLookup(raw, "rt1") + assertEquals("stored-1", found?.storedId) + assertEquals("Nightly report", found?.title) + + // Row without a title: notification falls back to the app name. + val untitled = parseActiveSessionLookup(raw, "rt2") + assertEquals("stored-2", untitled?.storedId) + assertNull(untitled?.title) + } + + @Test + fun `parseActiveSessionLookup accepts the JsonElement a typed RPC call returns`() { + val raw = """{"sessions":[{"id":"rt9","session_key":"stored-9","title":"T"}]}""" + val element = + kotlinx.serialization.json.Json + .parseToJsonElement(raw) + + val found = parseActiveSessionLookup(element, "rt9") + assertEquals("stored-9", found?.storedId) + assertEquals("T", found?.title) + } + + @Test + fun `parseActiveSessionLookup rejects unknown runtime ids and empty keys`() { + val raw = """{"sessions":[{"id":"rt1","session_key":"","status":"idle"}]}""" + + assertNull(parseActiveSessionLookup(raw, "rt1")) + assertNull(parseActiveSessionLookup(raw, "missing")) + assertNull(parseActiveSessionLookup(null, "rt1")) + assertNull(parseActiveSessionLookup(raw, "")) + assertNull(parseActiveSessionLookup("not json", "rt1")) + } +} + +class MessageCompleteRouteTest { + @Test + fun `active session routes to the reply path`() { + val route = messageCompleteRoute(holderSessionId = "rt1", toggleOn = true, eventSessionId = "rt1") + assertEquals(MessageCompleteRoute.Reply, route) + } + + @Test + fun `toggle off keeps legacy behavior for foreign sessions`() { + val route = messageCompleteRoute(holderSessionId = "rt1", toggleOn = false, eventSessionId = "other") + assertEquals(MessageCompleteRoute.Reply, route) + } + + @Test + fun `toggle on routes foreign sessions to the plain alert`() { + assertTrue( + messageCompleteRoute(holderSessionId = "rt1", toggleOn = true, eventSessionId = "other") is + MessageCompleteRoute.ForeignSession, + ) + } + + @Test + fun `toggle on with a cleared holder routes the pinned alert`() { + // After a background reconnect the holder is cleared: the user's own + // reply lands in the plain alert (resolved stored id keeps the tap + // working) instead of being dropped. + assertTrue( + messageCompleteRoute(holderSessionId = null, toggleOn = true, eventSessionId = "rt1") is + MessageCompleteRoute.ForeignSession, + ) + } + + @Test + fun `toggle off with a cleared holder still uses the reply path`() { + val route = messageCompleteRoute(holderSessionId = null, toggleOn = false, eventSessionId = "rt1") + assertEquals(MessageCompleteRoute.Reply, route) + } + + @Test + fun `legacy reply route retires the service when the wait is over`() { + // Toggle off + holder cleared by a reconnect: the route is Reply, and the + // Reply branch always ends in onReplyCompleted(generation). + assertEquals( + MessageCompleteRoute.Reply, + messageCompleteRoute(holderSessionId = null, toggleOn = false, eventSessionId = "rt1"), + ) + var completedGeneration: Long? = null + val controller = + BackgroundConnectionController( + snapshotProvider = { + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + }, + requestServiceComplete = { gen -> completedGeneration = gen }, + ) + controller.onReplyCompleted(7L) + assertEquals(7L, completedGeneration) + } +} 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 2d678c93..c35f6770 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 @@ -93,6 +93,8 @@ class SettingsViewModelTest { every { AuthManager.isTokensPerSecondEnabled() } returns true every { AuthManager.isModelProviderShown() } returns false every { AuthManager.isKeepConnectedInBackground() } returns false + every { AuthManager.isNotifySessionCompletions() } returns false + every { AuthManager.setNotifySessionCompletions(any()) } returns Unit every { AuthManager.getConnectionProfiles() } returns emptyList() every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId } every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/" From 6a75a2f1fbdf9bc157e3657ce69a4df4fb956018 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:53:00 +0300 Subject: [PATCH 104/107] docs: fix stale counts and reorder README for users (#1423) --- AGENTS.md | 21 +++--- README.md | 219 +++++++++++++++++++++++++++--------------------------- 2 files changed, 120 insertions(+), 120 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a9f19934..99c335c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,8 +212,8 @@ for the current inset implementation. ### Theme -`Theme.kt` uses a preset-based theme system with 6 built-in presets: Default, -Monochrome, Gruvbox, Catppuccin, AMOLED, and Nord. Each preset provides light +`Theme.kt` uses a preset-based theme system with 7 built-in presets: Default, +Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, and Garnet. Each preset provides light and dark color schemes plus matching semantic status colors (AMOLED is dark-only — light mode falls back to the default theme). Every preset is a `ThemePalette` template fill (`PaletteTemplate.kt`): raw @@ -274,7 +274,7 @@ gh pr create --base dev --title "fix(#N): description" --body "Closes #N" (`remember {}`, `buildAnnotatedString`, `LaunchedEffect`). Extract to a local `val` at the composable scope first. - Don't add new screens without checking if an existing one already covers the - functionality (28+ screens exist). Extend rather than duplicate. + functionality (30+ screens exist). Extend rather than duplicate. - **⚠ Never scope a dependency to `debugImplementation` if its import is used in `main/` source code.** The CI `release-compile` job catches this, but save the cycle. `okhttp3.logging.HttpLoggingInterceptor` is the classic example — it's @@ -301,22 +301,25 @@ com.m57.hermescontrol/ │ ├── local/ AuthManager, Room (ChatMessageEntity/Dao, HermesDatabase), AnalyticsCacheStore │ ├── model/ 40+ data classes for API responses + requests │ ├── remote/ ApiClient, Retrofit service, OkHttp provider, cookie management +│ ├── repository/ KanbanRepository, KanbanSyncCoordinator, VoiceNoteRepository │ ├── session/ ActiveSessionHolder +│ ├── update/ AppUpdateChecker, AppUpdateCache, AppUpdateState │ └── ws/ HermesWsClient, JSON-RPC models, WsEvent, BillingRepository ├── notification/ ChatNotificationService, NotificationReplyReceiver ├── theme/ Color, Theme, Motion, Spacing, Shapes, Type, HermesStatusColors -│ └── presets/ Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord +│ └── presets/ Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, Garnet ├── ui/ │ ├── common/ HermesScaffold, StateViews, SharedComponents, DetailDialog, DetailRows -│ └── 28 feature packages (achievements, analytics, authlogin, billing, channels, -│ chat, config, connect, cron, gateway, kanban, keys, landing, logs, mcp, -│ model, pairing, plugins, process, profiles, providers, sessions, settings, -│ skills, system, toolsets, webhooks) +│ └── 31 feature packages (achievements, analytics, authlogin, billing, bots, +│ channels, chat, config, connect, connectors, cron, files, gateway, kanban, +│ keys, landing, logs, mcp, memory, model, pairing, plugins, process, +│ profiles, providers, sessions, settings, skills, system, toolsets, +│ webhooks) ├── util/ CronExpressionFormatter, LocaleContextWrapper ├── HermesControlApp.kt Application class ├── Navigation.kt Drawer + NavDisplay + entry wiring ├── NavigationController.kt Central navigation guard (dedup) -├── NavigationKeys.kt @Serializable NavKey data objects (28 screens + 6 settings sub-pages) +├── NavigationKeys.kt @Serializable NavKey data objects (30 screens + 4 detail keys + 7 settings sub-pages) ├── ScreenRegistry.kt entry registrations for all NavKeys └── MainActivity.kt ``` diff --git a/README.md b/README.md index 10b40fc1..3d7d40cc 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,9 @@ ## Overview -**Hermes Mobile** is the native Android client for [Hermes Agent](https://hermes-agent.nousresearch.com). It connects to your Hermes gateway (REST API and WebSocket TUI Gateway), giving you pocket control over your AI assistant. Plain HTTP/WS connections are intended for trusted networks only; authentication does not encrypt the transport. +**Hermes Mobile** is a native Android app for controlling your [Hermes Agent](https://hermes-agent.nousresearch.com) from your phone. Chat with your agent, manage cron jobs, skills, models and more. ---- - -## Support the project - -If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi](https://ko-fi.com/m_5_7). +It connects to the Hermes dashboard (REST API and WebSocket gateway), so you need a running dashboard that your phone can reach. --- @@ -61,79 +57,41 @@ If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi ## Features -- **Real-Time Chat:** Message your agent with Room-backed local database history and inline reply notifications. -- **System Config:** Manage active profiles, installed skills, plugins, toolsets, and LLM model/provider selections. +- **Chat:** Talk to your agent. History is stored locally in Room, and notifications support inline replies. +- **Configuration:** Manage profiles, skills, plugins, toolsets, and model/provider selections. - **Operations:** Stream and filter live logs, manage cron jobs, edit environment keys, test webhooks, and monitor processes. -- **Gateway Status:** Monitor WebSocket connection, MCP servers, messaging channels, and OAuth providers. -- **Productivity:** View and manage tasks via integrated Kanban boards, track agent milestones, and browse session history. -- **Analytics & Billing:** Usage analytics dashboard and billing/subscription management. -- **Theming:** 7 built-in color presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, Garnet) plus Material You dynamic colors on supported devices. -- **Modern UX:** Native Material 3 design with pull-to-refresh, scroll-aware TopBar, and customizable bottom navigation. +- **Gateway status:** Check the WebSocket connection, MCP servers, messaging channels, and OAuth providers. +- **Productivity:** Manage tasks on Kanban boards, track agent milestones, and browse session history. +- **Analytics and billing:** See usage analytics and manage billing or subscriptions. +- **Theming:** Seven built-in presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, Garnet), plus Material You dynamic colors on supported devices. +- **Material 3 design:** Pull-to-refresh, a scroll-aware top bar, and customizable bottom navigation. --- ## Quick Start -1. Install Hermes Mobile from [F-Droid](https://f-droid.org/packages/com.m57.hermescontrol/) or download the APK from the [latest GitHub release](https://github.com/Hy4ri/hermes-mobile/releases/latest). -2. Start your Hermes dashboard on a host reachable from your phone. -3. Follow [Authentication](#authentication) below to connect. Use a trusted network for plain HTTP/WS connections. - -## Build from source - -### Prerequisites - -- **JDK 21+** (required for Kotlin compilation and the Gradle toolchain) -- An **Android SDK** matching the compile SDK in [`app/build.gradle.kts`](app/build.gradle.kts), available through Android Studio or the **Nix** development environment. Set `ANDROID_HOME` or configure `sdk.dir` in your local `local.properties`. - -### Build & Deploy - -1. **Clone the repository:** - ```bash - git clone https://github.com/Hy4ri/hermes-mobile.git - cd hermes-mobile - ``` -2. **Build the debug APK:** - ```bash - ./gradlew assembleDebug - ``` -3. **Install on your emulator/device:** - ```bash - adb install app/build/outputs/apk/debug/app-debug.apk - ``` - -_Note: For release builds, ensure keystore environment variables (`KEYSTORE_PATH`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`) are configured, or let the GitHub Actions release workflow handle it on tag push (`v*`)._ - -### Nix emulator - -The Nix development shell enables physical keyboard input in an existing -`hermes_dev` AVD configuration. It respects `ANDROID_AVD_HOME` and -`ANDROID_USER_HOME`, defaulting to `~/.android/avd`. - -Close any running emulator, then cold boot once to apply the setting: - -```bash -nix develop --command emulator -avd hermes_dev -no-snapshot-load -``` - -Subsequent launches can omit `-no-snapshot-load`. Create the `hermes_dev` AVD -first if it does not exist; the shell does not create one. +1. Install Hermes Mobile from [F-Droid](https://f-droid.org/packages/com.m57.hermescontrol/), or download the APK from the [latest GitHub release](https://github.com/Hy4ri/hermes-mobile/releases/latest). +2. Start your Hermes dashboard on a host your phone can reach. +3. Open the app, tap **Sign in**, and follow [Authentication](#authentication). --- ## Authentication -Once the app is installed, you need to point it at your Hermes gateway. The app auto-detects which auth mode the dashboard is using — just fill in the fields it shows. +The app detects which auth mode your dashboard uses and shows only the fields you need. + +> **Security:** Use HTTPS for remote connections. Plain HTTP/WS is for trusted local networks only, because authentication does not encrypt the transport. ### 1. Start the dashboard -On your host machine, start the dashboard: +On the host machine: ```bash hermes dashboard # loopback (127.0.0.1:9119) — no auth needed hermes dashboard --host 0.0.0.0 # LAN — requires auth ``` -For LAN access, configure credentials in `~/.hermes/config.yaml`: +For LAN access, set credentials in `~/.hermes/config.yaml`: ```yaml dashboard: @@ -144,14 +102,17 @@ dashboard: ### 2. Connect the app -Tap **Sign in** on the landing screen and enter the dashboard host and port. The app probes the dashboard and reveals the fields you need: +Tap **Sign in** on the landing screen and enter the dashboard host and port. The app probes the dashboard and shows the fields it needs. -| Auth mode | When | What you fill | -| -------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **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 | +**Token only** (dashboard on the same machine, bound to loopback) -Use HTTPS for remote connections. Use HTTP only on a trusted local network. +- Enter the **Token**. You can find it in `~/.hermes/dashboard-token.txt` or in `~/.hermes/.env` as `HERMES_DASHBOARD_SESSION_TOKEN`. +- The app can also extract the token from the dashboard page for you. + +**Basic auth** (dashboard on the LAN behind a password) + +- Enter your **Username** and **Password** (default `admin` / `hermes`). +- The app logs in, stores a session cookie, and requests a WebSocket ticket automatically. ### Cloudflare Access and custom headers @@ -164,63 +125,99 @@ Your [Cloudflare Access policy](https://developers.cloudflare.com/cloudflare-one 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. +**Other headers.** You can add any custom header name and value in the same editor. +To edit or remove saved headers, open **Custom headers** from login, or from 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. +**Where headers are sent.** + +- Profiles with the same server URL share headers. +- 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 go. +- Redirects outside that scope do not receive them, and WebSocket redirects are not followed. +- The app manages `Authorization`, `Cookie`, and transport headers itself. ### Connection profiles -Have multiple gateways? Switch between them in **Settings → Connection profiles**. Each profile stores its own host, port, and token — just tap to swap. - ---- - -## Project Structure - -``` -app/src/main/java/com/m57/hermescontrol/ -├── data/ # Local (Room, AuthManager), Remote (Retrofit, OkHttp), WS (WebSocket), Models -├── notification/ # Foreground service + inline reply for chat notifications -├── theme/ # Preset-based design system (6 themes), status colors, spacing, typography -├── ui/ # Compose feature screens + common components (HermesScaffold, StateViews) -├── util/ # CronExpressionFormatter, LocaleContextWrapper -└── Navigation*.kt # Navigation3 wiring, keys, screen registry, controller -``` - ---- - -## Tech Stack - -- **Language:** Kotlin with KSP -- **UI & Layout:** Jetpack Compose & Material 3 / Material You -- **Navigation:** Navigation3 (Compose-first Routing) -- **Networking:** Retrofit, OkHttp, Kotlinx Serialization -- **Database:** Room with SQLCipher encryption -- **Security:** `EncryptedSharedPreferences` (AES256-GCM), DataStore -- **Theming:** Built-in presets + Material You dynamic colors; see [Themes](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md) -- **Image Loading:** Coil -- **Testing:** JUnit, MockK, Turbine, Espresso, Compose UI testing -- **Formatting:** `ktlint` 1.8.0 style rules (checked automatically in CI) - -Dependency versions are maintained in [`gradle/libs.versions.toml`](gradle/libs.versions.toml); build configuration and dependency scopes live in [`app/build.gradle.kts`](app/build.gradle.kts). +Have multiple gateways? Switch between them in **Settings → Connection profiles**. Each profile stores its own host, port, and token. --- ## Contributing -Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for our branch workflow, code style guidelines, and PR checklist. +Contributions are welcome! Read [CONTRIBUTING.md](CONTRIBUTING.md) for the branch workflow, code style, and PR checklist. -### Translations +- **Translations:** Help translate the app on [Hosted Weblate](https://hosted.weblate.org/projects/hermes-mobile/hermes-mobile/). +- **Conventions and architecture:** [AGENTS.md](AGENTS.md). +- **Visual and interaction requirements:** [DESIGN.md](DESIGN.md). +- **Theme implementation:** [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md). -Help translate Hermes Mobile into your language on [Hosted Weblate](https://hosted.weblate.org/projects/hermes-mobile/hermes-mobile/)! +--- -For operational conventions and architecture notes, refer to [AGENTS.md](AGENTS.md). [DESIGN.md](DESIGN.md) defines visual and interaction requirements; [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md) explains theme implementation. +## Build from source + +### Prerequisites + +- **JDK 21+** (used for Kotlin compilation and the Gradle toolchain). +- An **Android SDK** matching the compile SDK in [`app/build.gradle.kts`](app/build.gradle.kts), from Android Studio or the **Nix** development environment. Set `ANDROID_HOME` or `sdk.dir` in `local.properties`. + +### Build and install + +```bash +git clone https://github.com/Hy4ri/hermes-mobile.git +cd hermes-mobile +./gradlew assembleDebug +adb install app/build/outputs/apk/debug/app-debug.apk +``` + +For release builds, set the keystore environment variables (`KEYSTORE_PATH`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`). Or let the GitHub Actions release workflow build on a `v*` tag push. + +### Nix emulator + +The Nix development shell turns on physical keyboard input in an existing `hermes_dev` AVD. It respects `ANDROID_AVD_HOME` and `ANDROID_USER_HOME`, defaulting to `~/.android/avd`. The shell does not create the AVD, so create `hermes_dev` first. + +Close any running emulator, then cold boot once to apply the setting: + +```bash +nix develop --command emulator -avd hermes_dev -no-snapshot-load +``` + +Later launches can omit `-no-snapshot-load`. + +--- + +## Project structure + +``` +app/src/main/java/com/m57/hermescontrol/ +├── data/ # Local (Room, AuthManager), Remote (Retrofit, OkHttp), WS (WebSocket), Models +├── notification/ # Foreground service + inline reply for chat notifications +├── theme/ # Preset-based design system (7 themes), status colors, spacing, typography +├── ui/ # Compose feature screens + common components (HermesScaffold, StateViews) +├── util/ # CronExpressionFormatter, LocaleContextWrapper +└── Navigation*.kt # Navigation3 wiring, keys, screen registry, controller +``` + +## Tech stack + +- **Language:** Kotlin with KSP +- **UI:** Jetpack Compose, Material 3 / Material You +- **Navigation:** Navigation3 +- **Networking:** Retrofit, OkHttp, Kotlinx Serialization +- **Database:** Room with SQLCipher encryption +- **Security:** `EncryptedSharedPreferences` (AES256-GCM), DataStore +- **Images:** Coil +- **Testing:** JUnit, MockK, Turbine, Espresso, Compose UI testing +- **Formatting:** `ktlint` 1.8.0 (checked in CI) + +Dependency versions live in [`gradle/libs.versions.toml`](gradle/libs.versions.toml). Build configuration and dependency scopes live in [`app/build.gradle.kts`](app/build.gradle.kts). + +--- + +## Support the project + +If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi](https://ko-fi.com/m_5_7). --- @@ -228,4 +225,4 @@ For operational conventions and architecture notes, refer to [AGENTS.md](AGENTS. Copyright © 2026 M57 (Hy4ri). -This project is licensed under the Apache License, Version 2.0. See the [LICENSE](LICENSE) file for details. +Licensed under the Apache License, Version 2.0. See the [LICENSE](LICENSE) file for details. From 9cf8c3818cb035674e344c2311e13fd06435a1b4 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:35:59 +0300 Subject: [PATCH 105/107] refactor(#1376): extract WebSocket client components (#1424) --- .../data/ws/WsTransportDeviceTest.kt | 296 +++ .../hermescontrol/data/ws/ConnectionHealth.kt | 140 ++ .../hermescontrol/data/ws/HermesWsClient.kt | 1668 ++--------------- .../hermescontrol/data/ws/ReconnectPolicy.kt | 39 + .../hermescontrol/data/ws/ReplayTracker.kt | 245 +++ .../m57/hermescontrol/data/ws/RpcChannel.kt | 353 ++++ .../m57/hermescontrol/data/ws/WsTransport.kt | 912 +++++++++ .../data/ws/ConnectionHealthTest.kt | 518 +++++ .../ws/HermesWsClientCharacterizationTest.kt | 532 ++++++ .../ws/HermesWsClientLoggingSecurityTest.kt | 79 +- .../data/ws/HermesWsClientTest.kt | 462 ++--- .../data/ws/HermesWsClientTypedCallTest.kt | 22 +- .../data/ws/ReconnectPolicyTest.kt | 98 + .../data/ws/ReplayTrackerTest.kt | 555 ++++++ .../hermescontrol/data/ws/RpcChannelTest.kt | 790 ++++++++ .../hermescontrol/data/ws/WsTransportTest.kt | 489 +++++ 16 files changed, 5285 insertions(+), 1913 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/data/ws/WsTransportDeviceTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionHealth.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/ReconnectPolicy.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/ReplayTracker.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/RpcChannel.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/WsTransport.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionHealthTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientCharacterizationTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/ReconnectPolicyTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/ReplayTrackerTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/RpcChannelTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/WsTransportTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/data/ws/WsTransportDeviceTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/data/ws/WsTransportDeviceTest.kt new file mode 100644 index 00000000..681267a0 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/data/ws/WsTransportDeviceTest.kt @@ -0,0 +1,296 @@ +package com.m57.hermescontrol.data.ws + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okio.ByteString +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 +import org.junit.runner.RunWith +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong + +@RunWith(AndroidJUnit4::class) +class WsTransportDeviceTest { + private class FakeWebSocket( + private val request: Request, + var accept: Boolean = true, + var queueSizeValue: Long = 0L, + ) : WebSocket { + val sentFrames = ConcurrentLinkedQueue() + val attempts = AtomicInteger(0) + val closedCode = AtomicInteger(-1) + val closedReason = + java.util.concurrent.atomic + .AtomicReference(null) + val canceled = AtomicBoolean(false) + + override fun request(): Request = request + + override fun queueSize(): Long = queueSizeValue + + override fun send(text: String): Boolean { + attempts.incrementAndGet() + if (!accept) return false + sentFrames.add(text) + return true + } + + override fun send(bytes: ByteString): Boolean { + attempts.incrementAndGet() + if (!accept) return false + sentFrames.add(bytes.utf8()) + return true + } + + override fun close( + code: Int, + reason: String?, + ): Boolean { + closedCode.set(code) + closedReason.set(reason) + return true + } + + override fun cancel() { + canceled.set(true) + } + } + + private class TestFixture { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val lock = Any() + val clock = AtomicLong(100L) + val dummyRequest: Request = Request.Builder().url("http://localhost/ws").build() + val openResponse: Response = + Response + .Builder() + .request(dummyRequest) + .protocol(Protocol.HTTP_1_1) + .code(101) + .message("Switching Protocols") + .build() + + val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = false + } + + val recordedEvents = ConcurrentLinkedQueue() + lateinit var transport: WsTransport + lateinit var rpc: RpcChannel + lateinit var health: ConnectionHealth + lateinit var replay: ReplayTracker + + init { + health = + ConnectionHealth( + scope = scope, + isConnected = { transport.isConnected }, + pingRequest = { _ -> }, + cancelSocket = { transport.cancelSocket() }, + pendingReply = { transport.pendingReply }, + nowMs = { clock.get() }, + ) + + replay = + ReplayTracker( + scope = scope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { recordedEvents.add(it) }, + ) + + rpc = + RpcChannel( + scope = scope, + json = json, + outboundLock = lock, + sendRequest = { method, params, onSent -> + transport.send(rpc.createRequest(method, params), onSent) + }, + sendFrame = { transport.sendDirectFrame(it) }, + removeQueuedMessage = { transport.removeQueuedMessage(it) }, + onIdleCheck = { transport.disconnectIfIdleInBackground() }, + onResult = { transport.onRpcResult(it) }, + onError = { transport.onRpcError(it) }, + ) + + transport = + WsTransport( + scope = scope, + outboundLock = lock, + reconnectPolicy = ReconnectPolicy(), + connectionHealth = health, + replayTracker = replay, + rpcChannel = rpc, + onRawMessage = { raw -> + rpc.handleIncomingFrame(raw, replay) { recordedEvents.add(it) } + }, + ) + } + + fun close() { + try { + transport.disconnect(clearPendingMessages = true) + } finally { + scope.cancel() + } + } + } + + @Test + fun transportRpcRoundTripWithStaleListenerIsolation() = + kotlinx.coroutines.runBlocking { + val fixture = TestFixture() + try { + val fakeWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L) + fixture.transport.connectionGenerationForTest.set(1) + fixture.transport.intentionalCloseForTest.set(false) + + val listenerGen1 = fixture.transport.createListenerForTest(1) + listenerGen1.onOpen(fakeWs, fixture.openResponse) + + assertTrue(fixture.transport.isConnected) + assertEquals(fakeWs, fixture.transport.webSocketForTest) + + val deferred = fixture.rpc.request("gateway.ping", emptyMap()) + val sentFrame = requireNotNull(fakeWs.sentFrames.poll()) + + val parsedSent = fixture.json.decodeFromString(sentFrame) + val reqId = parsedSent.id + + fixture.clock.set(200L) + val responseJson = """{"jsonrpc":"2.0","id":"$reqId","result":42.0}""" + listenerGen1.onMessage(fakeWs, responseJson) + + val result = withTimeout(2_000L) { deferred.await() } + assertEquals(JsonPrimitive(42.0), result) + + val event = requireNotNull(fixture.recordedEvents.poll()) + assertTrue(event is WsEvent.RpcResult) + assertEquals(reqId, (event as WsEvent.RpcResult).id) + assertEquals(200L, fixture.health.lastPongTimestamp) + + val staleListener = fixture.transport.createListenerForTest(0) + val staleWs = FakeWebSocket(fixture.dummyRequest, accept = true) + staleListener.onMessage(staleWs, """{"jsonrpc":"2.0","id":999,"result":99.0}""") + listenerGen1.onMessage(staleWs, """{"jsonrpc":"2.0","id":998,"result":88.0}""") + + assertTrue(fixture.transport.isConnected) + assertEquals(fakeWs, fixture.transport.webSocketForTest) + assertEquals(200L, fixture.health.lastPongTimestamp) + assertTrue(fixture.recordedEvents.isEmpty()) + } finally { + fixture.close() + } + } + + @Test + fun rejectingWsQueuesAndFlushesOnNextAcceptSocket() = + kotlinx.coroutines.runBlocking { + val fixture = TestFixture() + try { + val rejectingWs = FakeWebSocket(fixture.dummyRequest, accept = false, queueSizeValue = 1L) + fixture.transport.connectionGenerationForTest.set(1) + fixture.transport.intentionalCloseForTest.set(false) + + val listener1 = fixture.transport.createListenerForTest(1) + listener1.onOpen(rejectingWs, fixture.openResponse) + assertTrue(fixture.transport.isConnected) + + var recordedId: String? = null + var callbackRanUnderLock = false + val req1 = fixture.rpc.createRequest("test.one", emptyMap()) + val returnedId = + fixture.transport.send( + request = req1, + onSent = { id -> + callbackRanUnderLock = Thread.holdsLock(fixture.lock) + recordedId = id + }, + ) + + assertEquals(returnedId, req1.id.toString()) + assertEquals(returnedId, recordedId) + assertTrue(callbackRanUnderLock) + + val req2 = fixture.rpc.createRequest("test.two", emptyMap()) + fixture.transport.messageQueueForTest.add(fixture.rpc.encodeRequest(req2)) + + assertFalse(fixture.transport.isConnected) + assertEquals(ConnectionStatus.RECONNECTING, fixture.transport.connectionStatus.value) + assertNotNull(fixture.transport.outboundDrainJobForTest) + assertEquals(2, fixture.transport.messageQueueForTest.size) + + val acceptWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L) + val listenerReplacement = fixture.transport.createListenerForTest(1) + listenerReplacement.onOpen(acceptWs, fixture.openResponse) + + assertTrue(fixture.transport.isConnected) + assertEquals(ConnectionStatus.CONNECTED, fixture.transport.connectionStatus.value) + assertNull(fixture.transport.outboundDrainJobForTest) + + assertTrue(fixture.transport.messageQueueForTest.isEmpty()) + val frameList = acceptWs.sentFrames.toList() + assertEquals( + listOf(fixture.rpc.encodeRequest(req1), fixture.rpc.encodeRequest(req2)), + frameList, + ) + } finally { + fixture.close() + } + } + + @Test + fun authClosingDoesNotPrematurelyDropPendingRpcAndClosedHaltsReconnect() = + kotlinx.coroutines.runBlocking { + val fixture = TestFixture() + try { + val fakeWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L) + fixture.transport.connectionGenerationForTest.set(1) + fixture.transport.intentionalCloseForTest.set(false) + + val listener = fixture.transport.createListenerForTest(1) + listener.onOpen(fakeWs, fixture.openResponse) + assertTrue(fixture.transport.isConnected) + + val pendingDeferred = fixture.rpc.request("gateway.ping", emptyMap()) + assertTrue(fixture.rpc.hasPendingCalls()) + + listener.onClosing(fakeWs, 4401, "AUTH_EXPIRED") + assertTrue(fixture.transport.isConnected) + assertTrue(fixture.rpc.hasPendingCalls()) + assertFalse(pendingDeferred.isCompleted) + + listener.onClosed(fakeWs, 4401, "AUTH_EXPIRED") + assertFalse(fixture.transport.isConnected) + assertNull(fixture.transport.reconnectJobForTest) + assertTrue(fixture.rpc.hasPendingCalls()) + assertFalse(pendingDeferred.isCompleted) + + fixture.transport.disconnect(clearPendingMessages = true) + assertFalse(pendingDeferred.isCompleted) + + pendingDeferred.cancel() + } finally { + fixture.close() + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionHealth.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionHealth.kt new file mode 100644 index 00000000..89dcbbca --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionHealth.kt @@ -0,0 +1,140 @@ +package com.m57.hermescontrol.data.ws + +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +internal class ConnectionHealth( + private val scope: CoroutineScope, + private val isConnected: () -> Boolean, + private val pingRequest: suspend (Long) -> Unit, + private val cancelSocket: () -> Unit, + private val pendingReply: () -> Boolean, + private val nowMs: () -> Long = { System.nanoTime() / 1_000_000L }, + private val log: (String) -> Unit = {}, + private val debugLog: (String) -> Unit = {}, +) { + companion object { + internal const val HEARTBEAT_INTERVAL_MS = 15_000L + internal const val STALE_THRESHOLD_MS = 30_000L + internal const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L + } + + @Volatile + var lastPongTimestamp: Long = 0L + internal set + + private val _lastLatencyMs = MutableStateFlow(null) + val lastLatencyMs: StateFlow = _lastLatencyMs.asStateFlow() + + val isHealthy: Boolean + get() = isConnected() && (nowMs() - lastPongTimestamp < STALE_THRESHOLD_MS) + + private var healthJob: Job? = null + private var foregroundProbeJob: Job? = null + private var transportProbeJob: Job? = null + + fun start() { + healthJob?.cancel() + lastPongTimestamp = nowMs() + healthJob = + scope.launch { + while (isConnected()) { + delay(HEARTBEAT_INTERVAL_MS) + if (isConnected()) { + try { + ping(timeoutMs = LIVENESS_PROBE_TIMEOUT_MS) + } catch (e: Exception) { + log("Health check ping failed: ${e.message}") + } + } + if (!runHealthCheckPass()) break + } + } + } + + fun stop() { + healthJob?.cancel() + healthJob = null + _lastLatencyMs.value = null + } + + fun onInboundFrame() { + lastPongTimestamp = nowMs() + } + + suspend fun ping(timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS): Long { + val start = nowMs() + pingRequest(timeoutMs) + val latency = (nowMs() - start).coerceAtLeast(0L) + lastPongTimestamp = nowMs() + _lastLatencyMs.value = latency + return latency + } + + @VisibleForTesting + internal fun setLastLatencyForTest(latency: Long?) { + _lastLatencyMs.value = latency + } + + private fun runHealthCheckPass(): Boolean { + if (!isConnected()) return false + val staleMs = nowMs() - lastPongTimestamp + if (staleMs <= STALE_THRESHOLD_MS) return true + log("WebSocket stale (${staleMs / 1000}s without frames) — cancelling to trigger reconnect") + cancelSocket() + return false + } + + @VisibleForTesting + internal fun forceHealthCheckForTest(staleMillis: Long) { + lastPongTimestamp = nowMs() - staleMillis + runHealthCheckPass() + } + + fun probeLivenessOnWake(deferredOnce: Boolean = false) { + if (!isConnected()) return + foregroundProbeJob?.cancel() + foregroundProbeJob = + scope.launch { + val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess + if (alive) return@launch + if (pendingReply() && !deferredOnce) { + delay(3_000L) + probeLivenessOnWake(deferredOnce = true) + return@launch + } + log("Foreground liveness probe failed — cancelling socket") + cancelSocket() + } + } + + fun cancelForegroundProbe() { + foregroundProbeJob?.cancel() + foregroundProbeJob = null + } + + fun probeLivenessOnTransportChange( + timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS, + onFailureAction: () -> Unit = cancelSocket, + ) { + if (!isConnected()) return + transportProbeJob?.cancel() + transportProbeJob = + scope.launch { + debugLog("Network transport changed — probing WebSocket liveness") + val alive = runCatching { ping(timeoutMs) }.isSuccess + if (alive) { + debugLog("WebSocket liveness probe succeeded on new transport") + return@launch + } + log("Transport change liveness probe failed — cancelling socket") + onFailureAction() + } + } +} 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 fe87906b..634af15e 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 @@ -2,30 +2,17 @@ package com.m57.hermescontrol.data.ws import android.util.Log import androidx.annotation.VisibleForTesting -import com.m57.hermescontrol.BuildConfig -import com.m57.hermescontrol.data.local.AuthManager -import com.m57.hermescontrol.data.remote.CookieManager -import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher -import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.OkHttpProvider -import com.m57.hermescontrol.data.remote.await import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.contract.ClientCapabilitiesParams -import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams import com.m57.hermescontrol.data.ws.contract.RpcMethod import com.m57.hermescontrol.data.ws.contract.RpcMethods -import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams -import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -34,29 +21,14 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener -import okio.utf8Size -import java.util.Collections -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger -/** - * Connection status for the WebSocket client. - */ +/** Connection status for the WebSocket client. */ enum class ConnectionStatus { DISCONNECTED, CONNECTING, @@ -68,205 +40,16 @@ enum class ConnectionStatus { /** * WebSocket client for the Hermes Dashboard JSON-RPC 2.0 interface. - * - * Connects to `ws://HOST:PORT/api/ws?token=TOKEN`, auto-reconnects with - * exponential backoff, and emits parsed [WsEvent]s via [events] SharedFlow - * as well as direct callbacks. + * Facade coordinating transport, RPC correlation, replay tracking, and connection health. */ object HermesWsClient { private const val TAG = "HermesWsClient" - // ── Monotonic time helper (immune to NTP and wall-clock jumps) ───────── private fun monotonicTimeMs(): Long = System.nanoTime() / 1_000_000L - // ── Backoff settings (desktop parity: reconnect-backoff.ts full-jitter) ─ - - private var initialBackoffMs = 1_000L - private const val BACKOFF_BASE_MS = 300L - private const val BACKOFF_CAP_MS = 15_000L - private const val MAX_BACKOFF_MS = 30_000L - private const val BACKOFF_MULTIPLIER = 2.0 - private var testBackoffOverrideMs: Long? = null - - /** - * Test hook: the initial reconnect backoff, overridable so reconnect - * tests are deterministic instead of racing real time (CI runners - * routinely exceed fixed latch windows). Production keeps full-jitter backoff. - */ - @VisibleForTesting - internal fun setReconnectBackoffForTest(initialMillis: Long) { - initialBackoffMs = initialMillis - currentBackoff = initialMillis - testBackoffOverrideMs = initialMillis - } - - private fun nextBackoffDelayMs(): Long { - val override = testBackoffOverrideMs - if (override != null) return override - val ceiling = currentBackoff - if (ceiling <= 0L) return 0L - return (Math.random() * ceiling).toLong().coerceAtLeast(BACKOFF_BASE_MS).coerceAtMost(ceiling) - } - - private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024 - private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L - - /** - * Liveness thresholds aligned with reference clients (desktop/web): - * Ping every 15s using gateway.ping; 30s inbound silence deadline (issue #1165) - * as mobile secondary safety net for faster half-open socket recovery. - */ - private const val HEARTBEAT_INTERVAL_MS = 15_000L - private const val STALE_THRESHOLD_MS = 30_000L - private const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L - - // ── Internal state (all access through synchronized / atomic) ──────── - - private val requestId = AtomicInteger(0) - private val capabilityRequestIds = ConcurrentHashMap.newKeySet() - private val connectionGeneration = AtomicInteger(0) - private val connected = AtomicBoolean(false) - private val intentionalClose = AtomicBoolean(false) - private val acceptQueuedMessages = AtomicBoolean(true) - private val appInForeground = AtomicBoolean(true) - private val externalActivityConnectionLease = AtomicBoolean(false) - private val backgroundConnectionLease = AtomicBoolean(false) - private val messageQueue = ConcurrentLinkedQueue() - private val queuedMessagesById = ConcurrentHashMap() private val outboundLock = Any() - - @Volatile - private var webSocket: WebSocket? = null - - @Volatile - private var closingSocket: WebSocket? = null - - @Volatile - private var currentBackoff = initialBackoffMs - private val wsScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - /** - * Started lazily on the first [connect] instead of in [init]: the - * reconnect-on-network-change subscription only matters once a socket - * has actually been opened. init-time subscription also leaks a live - * real-IO collector into the unit-test JVM (class load), where - * NetworkMonitor emissions crashed it after unmockkAll. - */ - private val networkCollectorStarted = AtomicBoolean(false) - - @Volatile - private var reconnectJob: Job? = null - - @Volatile - private var outboundDrainJob: Job? = null - - // ── Sequence tracking & replay on reconnect (issue #1016) ───────────── - private val lastSeenSeq = ConcurrentHashMap() - - @Volatile - private var replayEpoch: String? = null - - @Volatile - private var replayInFlight: Boolean = false - private val replayHold = ConcurrentHashMap>>() - - @Volatile - private var replayJob: Job? = null - - // ── Health and Ping/Pong tracking ──────────────────────────────────── - - @Volatile - var lastPongTimestamp: Long = 0L - private set - - val isHealthy: Boolean - get() = isConnected && (monotonicTimeMs() - lastPongTimestamp < STALE_THRESHOLD_MS) - - // ── Observable latency tracking (issue #1017) ───────────────────────── - private val _lastLatencyMs = MutableStateFlow(null) - - /** Last measured round-trip latency in milliseconds via [ping], or null when disconnected / unmeasured. */ - val lastLatencyMs: StateFlow = _lastLatencyMs.asStateFlow() - - private var healthJob: Job? = null - - private fun startHealthTracking() { - healthJob?.cancel() - lastPongTimestamp = monotonicTimeMs() - healthJob = - wsScope.launch { - while (connected.get()) { - delay(HEARTBEAT_INTERVAL_MS) - if (connected.get()) { - try { - ping(timeoutMs = LIVENESS_PROBE_TIMEOUT_MS) - } catch (e: Exception) { - Log.w(TAG, "Health check ping failed: ${e.message}") - } - } - if (!runHealthCheckPass()) break - } - } - } - - /** - * Ultra-lightweight JSON-RPC liveness check and RTT measurement (issue #1017). - * Uses [WsMethods.GATEWAY_PING] which is answered inline in the WebSocket read - * loop (ws.py:350) and never queues behind worker dispatch pools. - * Updates [lastLatencyMs] and [lastPongTimestamp]. - */ - suspend fun ping(timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS): Long { - val start = monotonicTimeMs() - request(WsMethods.GATEWAY_PING, emptyMap(), timeoutMs = timeoutMs).await() - val latency = (monotonicTimeMs() - start).coerceAtLeast(0L) - lastPongTimestamp = monotonicTimeMs() - _lastLatencyMs.value = latency - return latency - } - - @VisibleForTesting - internal fun setLastLatencyForTest(latency: Long?) { - _lastLatencyMs.value = latency - } - - /** - * One liveness pass. Returns false when the tracking loop should stop: - * either the connection is already gone or the watchdog fired a cancel. - * - * Cancelling (not close()) forces an immediate onFailure, which rides the - * existing teardown path: generation bump, RECONNECTING, scheduleReconnect. - */ - private fun runHealthCheckPass(): Boolean { - if (!connected.get()) return false - val staleMs = monotonicTimeMs() - lastPongTimestamp - if (staleMs <= STALE_THRESHOLD_MS) return true - Log.w(TAG, "WebSocket stale (${staleMs / 1000}s without frames) — cancelling to trigger reconnect") - synchronized(outboundLock) { - if (!connected.get()) return false - webSocket?.cancel() - } - return false - } - - /** - * Test hook: backdate liveness past the staleness threshold and force one - * synchronous watchdog pass, so reconnect tests are deterministic instead - * of racing the real 15s/45s cadence. - */ - @VisibleForTesting - internal fun forceHealthCheckForTest(staleMillis: Long) { - lastPongTimestamp = monotonicTimeMs() - staleMillis - runHealthCheckPass() - } - - private fun stopHealthTracking() { - healthJob?.cancel() - healthJob = null - _lastLatencyMs.value = null - } - - // ── Public observable stream ───────────────────────────────────────── + private val reconnectPolicy = ReconnectPolicy() private val parsedEvents = MutableSharedFlow( @@ -274,34 +57,79 @@ object HermesWsClient { onBufferOverflow = BufferOverflow.DROP_OLDEST, ) - /** Collect this from ViewModels to receive all parsed [WsEvent]s. */ val events: SharedFlow = parsedEvents.asSharedFlow() - // ── Connection status flow ────────────────────────────────────────── - private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED) + private val connectionHealth = + ConnectionHealth( + scope = wsScope, + isConnected = { transport.isConnected }, + pingRequest = { timeoutMs -> + request(WsMethods.GATEWAY_PING, emptyMap(), timeoutMs = timeoutMs).await() + Unit + }, + cancelSocket = { transport.cancelSocket() }, + pendingReply = { transport.pendingReply }, + nowMs = ::monotonicTimeMs, + log = { Log.w(TAG, it) }, + debugLog = { Log.d(TAG, it) }, + ) - /** Observable connection status */ - val connectionStatus: StateFlow = _connectionStatus.asStateFlow() + private val replayTracker = + ReplayTracker( + scope = wsScope, + fetchEventsSince = { + call(RpcMethods.SESSION_EVENTS_SINCE, it, timeoutMs = 10_000L) + }, + emitEvent = { parsedEvents.tryEmit(it) }, + resolveStoredSessionId = { ActiveSessionHolder.resolveStoredSessionId(it) }, + onReplayFailure = { sid, e -> Log.w(TAG, "Replay failed for session $sid: ${e.message}") }, + ) - // ── Reply-pending tracking ───────────────────────────────────────── - // True while at least one agent turn is in flight. Drives the chat - // notification foreground service: it only runs while a reply is - // actually pending, instead of for the whole time the app is - // backgrounded (issue #794). - @Volatile - var pendingReply: Boolean = false + private val rpcChannel: RpcChannel = + RpcChannel( + scope = wsScope, + json = OkHttpProvider.json, + sendRequest = { method, params, onSent -> send(method, params, onSent) }, + sendFrame = { frameJson -> transport.sendDirectFrame(frameJson) }, + removeQueuedMessage = { transport.removeQueuedMessage(it) }, + onIdleCheck = { transport.disconnectIfIdleInBackground() }, + logRejected = { method, id -> Log.w(TAG, "Rejecting pending request on disconnect: $method (id=$id)") }, + outboundLock = outboundLock, + onResult = { id -> transport.onRpcResult(id) }, + onError = { id -> transport.onRpcError(id) }, + resolveStoredSessionId = { ActiveSessionHolder.resolveStoredSessionId(it) }, + logParseFailure = { e -> Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}") }, + decorateParams = { method, params -> WsProfileParams.decorate(method, params) }, + ) + + private val transport: WsTransport = + WsTransport( + scope = wsScope, + outboundLock = outboundLock, + reconnectPolicy = reconnectPolicy, + connectionHealth = connectionHealth, + replayTracker = replayTracker, + rpcChannel = rpcChannel, + onRawMessage = { rawFrame -> + rpcChannel.handleIncomingFrame(rawFrame, replayTracker) { event -> parsedEvents.tryEmit(event) } + }, + ) + + val connectionStatus: StateFlow = transport.connectionStatus + + var pendingReply: Boolean by transport::pendingReply private set - private val pendingPromptSubmits = ConcurrentHashMap.newKeySet() + var lastPongTimestamp: Long by connectionHealth::lastPongTimestamp + private set + + val isHealthy: Boolean get() = connectionHealth.isHealthy + val lastLatencyMs: StateFlow = connectionHealth.lastLatencyMs + + suspend fun ping(timeoutMs: Long = ConnectionHealth.LIVENESS_PROBE_TIMEOUT_MS): Long = + connectionHealth.ping(timeoutMs) - // ── Credential warning (issue #534) ───────────────────────────────── - // Backend surfaces `credential_warning` in `gateway.ready` / `session.info` - // WS payloads (desktop `requestDesktopOnboarding`). Mobile has no equivalent - // at the auth layer, so we extract it here once, globally, and let any - // screen render a banner that deep-links to ProvidersScreen. private val _credentialWarning = MutableStateFlow(null) - - /** Non-null when the backend reports a credential warning to resolve. */ val credentialWarning: StateFlow = _credentialWarning.asStateFlow() fun clearCredentialWarning() { @@ -309,7 +137,9 @@ object HermesWsClient { } init { - // Extract credential_warning from gateway.ready / session.info payloads. + wsScope.launch { + parsedEvents.collect { transport.observeEvent(it) } + } wsScope.launch { events.collect { event -> val data: Map? = @@ -319,1326 +149,238 @@ object HermesWsClient { else -> null } val warning = data?.get("credential_warning") as? String - if (!warning.isNullOrBlank()) { - _credentialWarning.value = warning - } + if (!warning.isNullOrBlank()) _credentialWarning.value = warning } } - // Track whether a reply is actually in flight so the chat notification - // foreground service only runs while the user is waiting for one - // (issue #794), instead of for the whole time the app is backgrounded. - // Also covers turns started from other devices on the same session. wsScope.launch { - events.collect { event -> - when (event) { - is WsEvent.MessageStart, - is WsEvent.MessageToken, - is WsEvent.ThinkingDelta, - is WsEvent.ReasoningDelta, - is WsEvent.ToolStart, - -> { - pendingReply = true - } - - is WsEvent.MessageComplete -> { - pendingReply = false - disconnectIfIdleInBackground() - } - - else -> {} - } - } + events.filterIsInstance().collect { ChangeEventHub.emit(it) } } - // Forward parsed change events (issue #784) to the hub so screens can - // refresh on change without touching this singleton — its real static - // init must never run inside unit tests (see ChangeEventHub). - wsScope.launch { - events - .filterIsInstance() - .collect { ChangeEventHub.emit(it) } - } - // Track gateway replay_epoch across gateway.ready events (issue #1016) wsScope.launch { events.collect { event -> if (event is WsEvent.GatewayReady) { - // Advertise support for gateway server→client requests (issue #1197). - // Older gateways may reject this method; that is harmless. runCatching { send( RpcMethods.CLIENT_CAPABILITIES.name, - encodeParams( + rpcChannel.encodeParams( RpcMethods.CLIENT_CAPABILITIES, ClientCapabilitiesParams(serverRequests = true), ), - onSent = { id -> capabilityRequestIds.add(id) }, + onSent = { id -> rpcChannel.registerCapabilityRequest(id) }, queueIfDisconnected = false, ) } val epoch = event.data?.get("replay_epoch") as? String - if (!epoch.isNullOrEmpty()) { - if (replayEpoch != null && replayEpoch != epoch) { - lastSeenSeq.clear() - } - replayEpoch = epoch - } + replayTracker.updateEpoch(epoch) } } } } - // ── Connection helpers ──────────────────────────────────────────────── - - @VisibleForTesting - val isConnected: Boolean get() = connected.get() - - private var foregroundProbeJob: Job? = null - private var transportProbeJob: Job? = null - - fun setAppForeground(foreground: Boolean) { - appInForeground.set(foreground) - if (!foreground) { - disconnectIfIdleInBackground() - return - } - probeLivenessOnWake() - } - - private fun probeLivenessOnWake(deferredOnce: Boolean = false) { - if (!connected.get()) return - foregroundProbeJob?.cancel() - foregroundProbeJob = - wsScope.launch { - val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess - if (alive) return@launch - if (pendingReply && !deferredOnce) { - delay(3_000L) - probeLivenessOnWake(deferredOnce = true) - return@launch - } - Log.w(TAG, "Foreground liveness probe failed — cancelling socket") - synchronized(outboundLock) { - if (connected.get()) webSocket?.cancel() - } - } - } - @VisibleForTesting - internal fun probeLivenessOnTransportChange( - timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS, - onFailureAction: () -> Unit = { - synchronized(outboundLock) { - if (connected.get()) webSocket?.cancel() - } - }, - ) { - if (!connected.get()) return - transportProbeJob?.cancel() - transportProbeJob = - wsScope.launch { - Log.d(TAG, "Network transport changed — probing WebSocket liveness") - val alive = runCatching { ping(timeoutMs) }.isSuccess - if (alive) { - Log.d(TAG, "WebSocket liveness probe succeeded on new transport") - return@launch - } - Log.w(TAG, "Transport change liveness probe failed — cancelling socket") - onFailureAction() - } - } + val isConnected: Boolean get() = transport.isConnected - fun acquireExternalActivityConnectionLease() { - externalActivityConnectionLease.set(true) - } + fun setAppForeground(foreground: Boolean) = transport.setAppForeground(foreground) - fun releaseExternalActivityConnectionLease() { - externalActivityConnectionLease.set(false) - disconnectIfIdleInBackground() - } + fun acquireExternalActivityConnectionLease() = transport.acquireExternalActivityConnectionLease() - fun acquireBackgroundConnectionLease() { - backgroundConnectionLease.set(true) - } + fun releaseExternalActivityConnectionLease() = transport.releaseExternalActivityConnectionLease() - fun releaseBackgroundConnectionLease() { - backgroundConnectionLease.set(false) - disconnectIfIdleInBackground() - } + fun acquireBackgroundConnectionLease() = transport.acquireBackgroundConnectionLease() + + fun releaseBackgroundConnectionLease() = transport.releaseBackgroundConnectionLease() @VisibleForTesting - internal fun hasBackgroundConnectionLease(): Boolean = backgroundConnectionLease.get() + internal fun hasBackgroundConnectionLease(): Boolean = transport.hasBackgroundConnectionLease() - private fun disconnectIfIdleInBackground() { - synchronized(outboundLock) { - if (!appInForeground.get() && - !externalActivityConnectionLease.get() && - !backgroundConnectionLease.get() && - !pendingReply && - pendingCalls.isEmpty() && - messageQueue.isEmpty() - ) { - disconnect() - } - } - } + private fun openSocket() = transport.openSocket() - /** Open a WebSocket connection using settings from [AuthManager]. */ - fun connect() { - if (networkCollectorStarted.compareAndSet(false, true)) { - wsScope.launch { - NetworkMonitor.networkChanges.collect { networkAvailable -> - reconnectForNetworkChange(networkAvailable) - } - } - wsScope.launch { - NetworkMonitor.transportChanges.collect { - probeLivenessOnTransportChange() - } - } - } - var socketToCancel: WebSocket? = null - val shouldOpen = - synchronized(outboundLock) { - acceptQueuedMessages.set(true) - if (_connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) { - connectionGeneration.incrementAndGet() - reconnectJob?.cancel() - reconnectJob = null - outboundDrainJob?.cancel() - outboundDrainJob = null - socketToCancel = webSocket - webSocket = null - closingSocket = null - connected.set(false) - stopHealthTracking() - } - if (connected.get()) { - Log.d(TAG, "Already connected — skipping") - return@synchronized false - } - if (_connectionStatus.value == ConnectionStatus.CONNECTING || - _connectionStatus.value == ConnectionStatus.RECONNECTING - ) { - Log.d(TAG, "Connection already in flight (${_connectionStatus.value}) — skipping") - return@synchronized false - } - // An explicit connect follows successful authentication and is - // therefore allowed to leave the terminal AUTH_EXPIRED state. - intentionalClose.set(false) - _connectionStatus.value = ConnectionStatus.CONNECTING - currentBackoff = initialBackoffMs - true - } - if (socketToCancel != null) { - rejectAllPending() - socketToCancel.cancel() - } - if (shouldOpen) openSocket() - } + fun connect() = transport.connect() - @VisibleForTesting - internal fun reconnectForNetworkChange( - networkAvailable: Boolean = NetworkMonitor.isConnected.value, + fun reconnectForNetworkChange( + networkAvailable: Boolean = com.m57.hermescontrol.data.remote.NetworkMonitor.isConnected.value, openReplacement: () -> Unit = ::openSocket, - ) { - val socketToCancel: WebSocket? - val shouldOpen: Boolean - synchronized(outboundLock) { - if (intentionalClose.get() || _connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) { - return - } - Log.d(TAG, "Default network changed — reconnecting WebSocket") - currentBackoff = initialBackoffMs - reconnectJob?.cancel() - reconnectJob = null - outboundDrainJob?.cancel() - outboundDrainJob = null - connectionGeneration.incrementAndGet() - socketToCancel = webSocket - webSocket = null - closingSocket = null - connected.set(false) - stopHealthTracking() - shouldOpen = networkAvailable && AuthManager.isAutoReconnect() - _connectionStatus.value = - when { - !networkAvailable -> ConnectionStatus.NO_NETWORK - shouldOpen -> ConnectionStatus.RECONNECTING - else -> ConnectionStatus.DISCONNECTED - } - rejectAllPending() - } - socketToCancel?.cancel() - if (shouldOpen) openReplacement() - } + ) = transport.reconnectForNetworkChange(networkAvailable, openReplacement) - /** - * If a session cookie is present (gated mode), mint a fresh WS ticket - * from the dashboard. The ticket is single-use and has a 30-second TTL, - * so we must mint a new one on every connect (first launch and reconnect). - * - * The session cookie is attached automatically by the shared CookieJar on - * OkHttpProvider.probe (issue #470), so we no longer inject it manually. - * - * Returns true if the token is ready (either because we are not in gated mode, - * or because ticket refresh succeeded). Returns false if we are in gated mode - * and ticket refresh failed or cannot be performed. - */ - internal suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean { - val isGated = - try { - AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" - } catch (_: IllegalStateException) { - // serverStore not initialized yet (transient early call); treat - // as loopback so a stale-token refresh still runs. Log so a real - // misconfiguration isn't silently swallowed. - Log.w(TAG, "serverStore uninitialized during WS handshake; assuming non-gated") - false - } - if (!isGated) { - // The loopback dashboard token is regenerated on every server - // restart. Refresh it before each WebSocket handshake so automatic - // reconnect does not get stuck in AUTH_EXPIRED with a stale token. - val token = - runCatching { - DashboardSessionTokenRefresher.refreshAsync() - }.getOrNull() - synchronized(outboundLock) { - if (isCurrentGeneration(generation) && token != null) { - runCatching { AuthManager.setToken(token) } - } - } - return true - } + internal suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean = + transport.refreshWsTicketIfNeeded(generation) - val ticketResult = requestWsTicket() - if (!ticketResult.ticket.isNullOrBlank()) { - synchronized(outboundLock) { - if (!isCurrentGeneration(generation)) return false - AuthManager.setToken(ticketResult.ticket) - } - if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed") - return true - } - val status = - when { - ticketResult.exception -> ConnectionStatus.RECONNECTING - ticketResult.httpCode == 401 || ticketResult.httpCode == 403 -> ConnectionStatus.AUTH_EXPIRED - ticketResult.httpCode == 408 || ticketResult.httpCode == 429 -> ConnectionStatus.RECONNECTING - ticketResult.httpCode != null && ticketResult.httpCode >= 500 -> ConnectionStatus.RECONNECTING - else -> ConnectionStatus.DISCONNECTED - } - return handleWsTicketRefreshFailure(status, generation) - } + internal suspend fun mintWsTicket(): String? = transport.mintWsTicket() - /** - * Mint a fresh WS ticket for a *secondary* consumer (e.g. the kanban - * events stream) WITHOUT touching the shared token slot. - * - * The shared slot is a single-use ticket that the chat WebSocket consumes - * at handshake. If two clients mint and stash into the same slot on their - * own schedules, each can grab the other's just-consumed ticket and the - * pair starves each other into a permanent reconnect loop. Secondary - * consumers must use their own ticket per connection. - * - * Gated mode: cookie-auth'd POST to /api/auth/ws-ticket, returns the - * ticket. Loopback mode: refresh the dashboard token and return it. - * Returns null when a ticket could not be obtained. - */ - internal suspend fun mintWsTicket(): String? { - val isGated = - try { - AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" - } catch (_: IllegalStateException) { - false - } - if (!isGated) { - DashboardSessionTokenRefresher.refreshAsync() - return AuthManager.getToken() - } - return requestWsTicket().ticket - } + fun disconnect(clearPendingMessages: Boolean = false) = transport.disconnect(clearPendingMessages) - private data class TicketRequestResult( - val ticket: String?, - val httpCode: Int?, - val exception: Boolean = false, - ) - - /** POST /api/auth/ws-ticket (cookie-auth'd via the shared CookieJar) and parse the ticket. */ - private suspend fun requestWsTicket(): TicketRequestResult { - try { - val client = OkHttpProvider.probe - val request = - Request - .Builder() - .url(AuthManager.endpointForBuild().resolve("api/auth/ws-ticket").toString()) - .post("{}".toRequestBody()) - .build() - - val (code, body) = - withContext(Dispatchers.IO) { - if (CookieManager.isInitialized()) { - CookieManager.useStore(CookieManager.cookieJar.currentServer()) - } - client.newCall(request).await().use { resp -> - resp.code to resp.body.string() - } - } - - if (code in 200..299) { - // Real JSON decode — the ticket is base64url and may contain - // characters a naive [^"]+ regex cannot extract (see - // testGatedMode_parsesEscapedTicketFromRealJsonShape). - val parsed = - runCatching { - OkHttpProvider.json.decodeFromString(body) - }.getOrNull() - val ticket = parsed?.ticket - if (!ticket.isNullOrBlank()) { - return TicketRequestResult(ticket, null) - } - Log.w(TAG, "WS ticket mint failed: unparseable ticket response") - } else { - Log.w(TAG, "WS ticket mint failed: HTTP $code") - return TicketRequestResult(null, code) - } - } catch (e: Exception) { - // Include the stack: the exception class alone (e.g. - // NetworkOnMainThreadException) can't show WHICH caller on what - // thread tripped the mint (main-scope kanban events connect vs - // the WS reconnect path). - Log.w(TAG, "WS ticket mint failed: ${e.javaClass.simpleName}", e) - return TicketRequestResult(null, null, exception = true) - } - return TicketRequestResult(null, null) - } - - private fun handleWsTicketRefreshFailure( - status: ConnectionStatus, - generation: Int, - ): Boolean { - synchronized(outboundLock) { - if (!isCurrentGeneration(generation)) return false - _connectionStatus.value = status - if (status == ConnectionStatus.RECONNECTING) scheduleReconnect() - } - return false - } - - /** Cleanly close the WebSocket and stop auto-reconnect. */ - fun disconnect(clearPendingMessages: Boolean = false) { - ActiveSessionHolder.clear() - synchronized(outboundLock) { - intentionalClose.set(true) - acceptQueuedMessages.set(!clearPendingMessages) - connectionGeneration.incrementAndGet() - reconnectJob?.cancel() - reconnectJob = null - outboundDrainJob?.cancel() - outboundDrainJob = null - foregroundProbeJob?.cancel() - foregroundProbeJob = null - stopHealthTracking() - webSocket?.close(1000, "Client closed") - webSocket = null - closingSocket = null - capabilityRequestIds.clear() - if (clearPendingMessages) { - backgroundConnectionLease.set(false) - messageQueue.clear() - queuedMessagesById.clear() - pendingPromptSubmits.clear() - pendingReply = false - lastSeenSeq.clear() - replayHold.clear() - replayEpoch = null - replayJob?.cancel() - replayJob = null - replayInFlight = false - } - connected.set(false) - _connectionStatus.value = ConnectionStatus.DISCONNECTED - } - } - - // ── Awaited RPC request layer (issue #526) ───────────────────────── - // Mirrors desktop apps/shared JsonRpcGatewayClient.request(): an in-flight - // map with a per-call timeout and rejectAllPending on socket close, so a - // dropped RPC response can't leave a caller awaiting forever. - - /** Thrown when an awaited [request] is neither answered nor rejected within [REQUEST_TIMEOUT_MS], or is rejected by a disconnect. */ class HermesRpcException( message: String, val code: Int = 0, val data: JsonElement? = null, ) : Exception(message) - /** - * Default per-request timeout. Matches the desktop - * `apps/shared` `JsonRpcGatewayClient.DEFAULT_REQUEST_TIMEOUT_MS` (120s), - * so legitimately long agent turns are not pruned early. - */ const val REQUEST_TIMEOUT_MS: Long = 120_000L - /** A single in-flight [request] awaiting its RPC result/error. */ - private data class PendingCall( - val method: String, - val deferred: CompletableDeferred, - var timeoutJob: Job? = null, - val suppressErrorEvent: Boolean = false, - ) - - /** Tracks in-flight [request] calls by their JSON-RPC id. */ - private val pendingCalls = ConcurrentHashMap() - - /** - * Send a JSON-RPC request that expects a result and returns a - * [CompletableDeferred] for it — mirroring the desktop - * `JsonRpcGatewayClient.request()`. Fire-and-forget notifications should - * keep using [send]. - * - * The deferred is completed on the matching [WsEvent.RpcResult] / - * [WsEvent.RpcError], rejected after [timeoutMs] (no response), or - * rejected by [rejectAllPending] when the socket closes. - * - * Untyped escape hatch, module-internal on purpose: gateway methods in the contract go through - * [call] / [requestTyped] with an `RpcMethods` descriptor. Only `gateway.ping` and tests use this. - */ internal fun request( 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, suppressErrorEvent = suppressErrorEvent) - } - deferred.invokeOnCompletion { cause -> - if (cause is CancellationException) { - pendingCalls.remove(id)?.let { call -> - call.timeoutJob?.cancel() - removeQueuedMessage(id) - disconnectIfIdleInBackground() - } - } - } - // Arm the per-request timeout (fires if the server never answers). - pendingCalls[id]?.timeoutJob = - wsScope.launch { - delay(timeoutMs) - resolvePending(id, null, JsonRpcError(-1, "Request timed out: $method")) - } - return deferred - } + ): CompletableDeferred = rpcChannel.request(method, params, timeoutMs, suppressErrorEvent) - /** - * Non-suspending typed twin of [request] for callers that must enqueue inside a - * non-suspending owner check and await the raw result themselves. - */ fun requestTyped( method: RpcMethod, params: P, timeoutMs: Long = REQUEST_TIMEOUT_MS, suppressErrorEvent: Boolean = false, - ): CompletableDeferred = request(method.name, encodeParams(method, params), timeoutMs, suppressErrorEvent) + ): CompletableDeferred = + request(method.name, rpcChannel.encodeParams(method, params), timeoutMs, suppressErrorEvent) - /** - * Send a typed JSON-RPC request and await its deserialized result. - * - * Params classes are strict writers; `profile` is a field ONLY on methods - * where callers pass an explicit override (e.g. session.create, session.resume); - * otherwise it is injected by WsProfileParams (an explicit value wins). - * Result classes are tolerant readers. - */ suspend fun call( method: RpcMethod, params: P, timeoutMs: Long = REQUEST_TIMEOUT_MS, suppressErrorEvent: Boolean = false, - ): R { - val deferred = requestTyped(method, params, timeoutMs, suppressErrorEvent) - val result = - try { - deferred.await() - } catch (e: CancellationException) { - deferred.cancel(e) - throw e - } - val element = result.toJsonElement() - return OkHttpProvider.json.decodeFromJsonElement(method.result, element) - } + ): R = rpcChannel.awaitResult(method, requestTyped(method, params, timeoutMs, suppressErrorEvent)) - private fun encodeParams( - method: RpcMethod, - params: P, - ): JsonObject { - val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params) - require(encoded is JsonObject) { - "RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}" - } - return encoded - } - - /** Complete (or fail) a single pending call and cancel its timer. */ - private fun resolvePending( - id: String, - result: Any?, - error: JsonRpcError?, - ) { - val call = pendingCalls.remove(id) ?: return - removeQueuedMessage(id) - call.timeoutJob?.cancel() - if (error != null) { - call.deferred.completeExceptionally( - HermesRpcException( - message = error.message, - code = error.code, - data = error.data, - ), - ) - } else { - call.deferred.complete(result) - } - disconnectIfIdleInBackground() - } - - /** - * Fail and clear every in-flight [request]. Called on disconnect / - * reconnect so callers awaiting a result don't hang across a socket - * close — mirrors desktop `JsonRpcGatewayClient.rejectAllPending(error)` - * invoked on socket close. - */ fun rejectAllPending( error: HermesRpcException = HermesRpcException("Connection lost — request cancelled"), - ) { - if (pendingCalls.isEmpty()) return - val snapshot = pendingCalls.toList() - pendingCalls.clear() - for ((id, call) in snapshot) { - removeQueuedMessage(id) - Log.w(TAG, "Rejecting pending request on disconnect: ${call.method} (id=$id)") - call.timeoutJob?.cancel() - call.deferred.completeExceptionally(error) - } - } + ) = rpcChannel.rejectAllPending(error) - /** - * Answer a gateway-originated JSON-RPC request. This deliberately bypasses - * [send]: server requests already have an id allocated by the gateway and - * responses must not contain a method or receive a new client id. - */ fun respondToServerRequest( id: String, result: JsonElement, - ): Boolean { - if (id.isBlank()) return false - val json = - buildJsonObject { - put("jsonrpc", "2.0") - put("id", id) - put("result", result) - }.toString() - synchronized(outboundLock) { - val ws = webSocket - if (ws == null || !connected.get()) return false - return ws.send(json) - } - } + ): Boolean = rpcChannel.respondToServerRequest(id, result) - /** Answer a gateway-originated request with a standard JSON-RPC error. */ fun respondToServerRequestError( id: String, code: Int, message: String, - ): Boolean { - if (id.isBlank()) return false - val json = - buildJsonObject { - put("jsonrpc", "2.0") - put("id", id) - put( - "error", - buildJsonObject { - put("code", code) - put("message", message) - }, - ) - }.toString() - synchronized(outboundLock) { - val ws = webSocket - if (ws == null || !connected.get()) return false - return ws.send(json) - } - } + ): Boolean = rpcChannel.respondToServerRequestError(id, code, message) - // ── Send helpers ───────────────────────────────────────────────────── - - /** - * Send a JSON-RPC request with the given [method] and optional [params]. - * - * Untyped escape hatch, module-internal on purpose: contract methods use the typed [send] overload. - * Kept for the legacy `*.respond` fallbacks (clarify, sudo, secret, vault) that have no contract entry. - * @return the request id used (can be matched against [WsEvent.RpcResult]). - */ internal fun send( method: String, params: Map = emptyMap(), onSent: ((String) -> Unit)? = null, - ): String = send(method, params, onSent, true) + ): String = send(method, params, onSent, queueIfDisconnected = true) - /** - * Send a typed fire-and-forget JSON-RPC request. - * - * Params classes are strict writers; `profile` is a field ONLY on methods - * where callers pass an explicit override (session.create, session.resume); - * otherwise it is injected by WsProfileParams (an explicit value wins). - * - * @return the request id used (can be matched against [WsEvent.RpcResult]). - */ fun send( method: RpcMethod, params: P, onSent: ((String) -> Unit)? = null, - ): String = send(method.name, encodeParams(method, params), onSent) + ): String = send(method.name, rpcChannel.encodeParams(method, params), onSent) private fun send( method: String, params: Map, onSent: ((String) -> Unit)?, queueIfDisconnected: Boolean, - ): String { - val id = requestId.incrementAndGet().toString() - val decoratedParams = WsProfileParams.decorate(method, params) - val request = - JsonRpcRequest( - id = id, - method = method, - params = decoratedParams.mapValues { it.value.toJsonElement() }, - ) - val json = OkHttpProvider.json.encodeToString(request) - if (BuildConfig.DEBUG) { - Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false)) - } - var reconnect = false - synchronized(outboundLock) { - if (method == WsMethods.PROMPT_SUBMIT) { - pendingPromptSubmits.add(id) - pendingReply = true - } - val ws = webSocket - if (ws != null && connected.get()) { - // Never replay send(true): the server may have executed it even if its response is lost. - if (ws.send(json)) { - onSent?.invoke(id) - } else { - if (webSocket !== ws || !acceptQueuedMessages.get()) return@synchronized - if (queueIfDisconnected && isRetryableMessage(json)) { - onSent?.invoke(id) - Log.w(TAG, "WS rejected outgoing message — queuing for reconnect") - queueMessage(id, json) - recoverRejectedSocket(ws) - } else { - Log.w(TAG, "WS rejected oversized outgoing message — not retrying") - } - } - } else if (acceptQueuedMessages.get() && queueIfDisconnected) { - if (isRetryableMessage(json)) { - onSent?.invoke(id) - Log.d(TAG, "WS disconnected — queuing message") - queueMessage(id, json) - reconnect = true - } else { - Log.w(TAG, "WS disconnected with oversized outgoing message — not queueing") - } - } - } - if (reconnect) connect() - return id - } + ): String = + transport.send( + rpcChannel.createRequest(method, params), + onSent = onSent, + queueIfDisconnected = queueIfDisconnected, + ) - private fun isRetryableMessage(json: String): Boolean { - if (json.length > MAX_OUTBOUND_MESSAGE_BYTES) return false - if (json.length <= MAX_OUTBOUND_MESSAGE_BYTES / 4) return true - return json.utf8Size() <= MAX_OUTBOUND_MESSAGE_BYTES.toLong() - } - - private fun queueMessage( - id: String, - json: String, - ) { - if (queuedMessagesById.putIfAbsent(id, json) == null) messageQueue.add(json) - } - - private fun removeQueuedMessage(id: String) { - synchronized(outboundLock) { - queuedMessagesById.remove(id)?.let(messageQueue::remove) - } - } - - private fun markQueuedMessageSent(json: String) { - queuedMessagesById.entries.removeIf { it.value == json } - } - - private fun recoverRejectedSocket(ws: WebSocket) { - connected.set(false) - if (closingSocket === ws) return - _connectionStatus.value = ConnectionStatus.RECONNECTING - if (ws.queueSize() == 0L) { - ws.cancel() - scheduleReconnect() - return - } - if (intentionalClose.get()) return - outboundDrainJob?.cancel() - outboundDrainJob = - wsScope.launch { - delay(OUTBOUND_DRAIN_TIMEOUT_MS) - synchronized(outboundLock) { - if (!connected.get() && !intentionalClose.get() && webSocket === ws) { - ws.cancel() - outboundDrainJob = null - scheduleReconnect() - } - } - } - } - - /** Convenience: submit a user prompt to an existing session. */ fun sendMessage( sessionId: String, text: String, onSent: ((String) -> Unit)? = null, queued: Boolean = false, - ): String = - send( - method = RpcMethods.PROMPT_SUBMIT, - params = - PromptSubmitParams( - sessionId = sessionId, - text = text, - queued = queued.takeIf { it }, - ), - onSent = onSent, - ) + ): String = transport.sendMessage(sessionId, text, onSent, queued) - /** - * Convenience: redirect the active model turn while it is still generating - * (backend `session.redirect`). Fire-and-forget — the backend either rewrites - * the live turn, queues the correction as the next turn, or rejects it (the - * caller's ViewModel handles the `4010` fall-back to [sendMessage]). - */ fun sendRedirect( sessionId: String, text: String, onSent: ((String) -> Unit)? = null, - ): String = - send( - method = RpcMethods.SESSION_REDIRECT, - params = - SessionCorrectionParams( - sessionId = sessionId, - text = text, - ), - onSent = onSent, - ) - - // ── Sequence tracking & replay on reconnect (issue #1016) ───────────── - - private fun triggerReplay() { - // Arm the hold SYNCHRONOUSLY (still under onOpen's outboundLock) so a - // live frame racing the replay coroutine is parked instead of advancing - // lastSeenSeq past events the replay would then skip-drop (G2). - if (lastSeenSeq.isEmpty() || replayInFlight) return - replayInFlight = true - val sessionsToReplay = lastSeenSeq.keys().toList() - for (sid in sessionsToReplay) { - replayHold[sid] = Collections.synchronizedList(mutableListOf()) - } - replayJob?.cancel() - replayJob = - wsScope.launch { - fetchReplay(sessionsToReplay) - } - } - - private suspend fun fetchReplay(sessionsToReplay: List = lastSeenSeq.keys().toList()) { - try { - for (sid in sessionsToReplay) { - val lastSeen = lastSeenSeq[sid] ?: continue - try { - val res = - call( - RpcMethods.SESSION_EVENTS_SINCE, - SessionEventsSinceParams(sessionId = sid, lastSeen = lastSeen), - timeoutMs = 10_000L, - ) - - val epoch = res.epoch - val epochChanged = - !epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch - val truncated = res.truncated == true - val latestSeq = res.latestSeq - - if (epochChanged) replayEpoch = epoch - - if (epochChanged || truncated) { - // Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted). - // Partial replay would silently hole the transcript — fast-forward watermark - // and request full transcript resync. - if (epochChanged) lastSeenSeq.clear() - if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq - parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid)) - continue - } - val eventsList = res.events - if (eventsList != null) { - for (element in eventsList) { - @Suppress("UNCHECKED_CAST") - val eventMap = (element as? JsonObject)?.toAny() as? Map ?: continue - val eventSeq = (eventMap["seq"] as? Number)?.toInt() - val eventSid = (eventMap["session_id"] as? String) ?: sid - if (eventSeq != null && eventSid.isNotEmpty()) { - val prev = lastSeenSeq[eventSid] ?: 0 - if (eventSeq <= prev) continue - lastSeenSeq[eventSid] = eventSeq - } - val parsedEvent = EventParser.parseParams(eventMap) - val finalEvent = - if (parsedEvent is WsEvent.MessageComplete) { - parsedEvent.copy( - storedSessionId = - ActiveSessionHolder.resolveStoredSessionId( - parsedEvent.sessionId, - ), - ) - } else { - parsedEvent - } - parsedEvents.tryEmit(finalEvent) - } - } - } catch (e: Exception) { - Log.w(TAG, "Replay failed for session $sid: ${e.message}") - parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid)) - } - } - } finally { - withContext(NonCancellable) { - for (sid in sessionsToReplay) { - val held = replayHold.remove(sid) - if (held != null) { - synchronized(held) { - for ((heldSeq, heldEvent) in held) { - if (heldSeq != null) { - val prev = lastSeenSeq[sid] ?: 0 - if (heldSeq <= prev) continue - lastSeenSeq[sid] = heldSeq - } - parsedEvents.tryEmit(heldEvent) - } - } - } - } - replayInFlight = false - } - } - } + ): String = transport.sendRedirect(sessionId, text, onSent) @VisibleForTesting - internal fun getSeqWatermarks(): Map = HashMap(lastSeenSeq) + internal fun getSeqWatermarks(): Map = replayTracker.getSeqWatermarks() @VisibleForTesting internal fun setSeqWatermark( sessionId: String, seq: Int, - ) { - lastSeenSeq[sessionId] = seq - } + ) = replayTracker.setSeqWatermark(sessionId, seq) @VisibleForTesting - internal fun clearSeqWatermarks() { - lastSeenSeq.clear() - replayHold.clear() - replayEpoch = null - replayInFlight = false - } + internal fun clearSeqWatermarks() = replayTracker.clearSeqWatermarks() @VisibleForTesting - internal fun isReplayInFlight(): Boolean = replayInFlight + internal fun isReplayInFlight(): Boolean = replayTracker.isReplayInFlight() @VisibleForTesting - internal fun setReplayEpochForTest(epoch: String?) { - replayEpoch = epoch - } + internal fun setReplayEpochForTest(epoch: String?) = replayTracker.setReplayEpochForTest(epoch) @VisibleForTesting - internal suspend fun fetchReplayForTest() { - fetchReplay() - } - - // ── Internal ───────────────────────────────────────────────────────── - - private fun openSocket() { - val generation = - synchronized(outboundLock) { - if (intentionalClose.get()) return - connectionGeneration.incrementAndGet() - } - wsScope.launch(Dispatchers.IO) { - if (!refreshWsTicketIfNeeded(generation)) { - Log.w(TAG, "Aborting openSocket: WS ticket refresh failed") - return@launch - } - if (!isCurrentGeneration(generation)) return@launch - val url = AuthManager.wsUrl() - val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED") - if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl") - - val request = Request.Builder().url(url).build() - val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation)) - synchronized(outboundLock) { - if (connectionGeneration.get() == generation && !intentionalClose.get()) { - webSocket = newSocket - } else { - newSocket.cancel() - } - } - } - } - - private fun isCurrentGeneration(generation: Int): Boolean = - connectionGeneration.get() == generation && !intentionalClose.get() - - private fun scheduleReconnect() { - synchronized(outboundLock) { - if (intentionalClose.get() || - _connectionStatus.value == ConnectionStatus.AUTH_EXPIRED || - reconnectJob?.isActive == true - ) { - return - } - if (!AuthManager.isAutoReconnect()) { - if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled") - _connectionStatus.value = ConnectionStatus.DISCONNECTED - return - } - if (!NetworkMonitor.isConnected.value) { - Log.d(TAG, "No network available — delaying reconnect scheduling") - _connectionStatus.value = ConnectionStatus.NO_NETWORK - return - } - val reconnectDelay = nextBackoffDelayMs() - currentBackoff = - (currentBackoff * BACKOFF_MULTIPLIER) - .toLong() - .coerceAtMost(MAX_BACKOFF_MS) - if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${reconnectDelay}ms …") - - reconnectJob = - wsScope.launch { - delay(reconnectDelay) - val owner = currentCoroutineContext()[Job] - val shouldOpen = - synchronized(outboundLock) { - if (reconnectJob !== owner) { - false - } else { - reconnectJob = null - !intentionalClose.get() && - !connected.get() && - _connectionStatus.value != ConnectionStatus.AUTH_EXPIRED - } - } - if (shouldOpen) openSocket() - } - } - } - - private fun isTerminalAuthClose( - code: Int, - reason: String, - ): Boolean = - code == 4401 || code == 4403 || - reason.contains("unauthorized", ignoreCase = true) || - reason.startsWith("auth:", ignoreCase = true) - - // ── Listener ───────────────────────────────────────────────────────── - - private class WsListenerImpl( - private val generation: Int, - ) : WebSocketListener() { - private fun isCurrent(): Boolean = isCurrentGeneration(generation) - - private fun consumeCapabilityResponse(event: WsEvent): Boolean { - val id = - when (event) { - is WsEvent.RpcResult -> event.id - is WsEvent.RpcError -> event.id - else -> return false - } - // send() registers capability request IDs via onSent while holding - // outboundLock. Take the same lock here so a very fast gateway - // response cannot be consumed before its ID has been registered. - val isCapabilityResponse = - synchronized(outboundLock) { - capabilityRequestIds.remove(id) - } - if (!isCapabilityResponse) return false - - removeQueuedMessage(id) - return true - } - - override fun onOpen( - webSocket: WebSocket, - response: Response, - ) { - synchronized(outboundLock) { - if (!isCurrent()) { - webSocket.close(1000, "Superseded") - return - } - HermesWsClient.webSocket = webSocket - closingSocket = null - outboundDrainJob?.cancel() - outboundDrainJob = null - Log.i(TAG, "WebSocket opened") - connected.set(true) - _connectionStatus.value = ConnectionStatus.CONNECTED - currentBackoff = initialBackoffMs - startHealthTracking() - - while (true) { - val msg = messageQueue.peek() ?: break - if (!isRetryableMessage(msg)) { - Log.w(TAG, "Dropping oversized queued message") - messageQueue.poll() - markQueuedMessageSent(msg) - continue - } - if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg)) - if (!webSocket.send(msg)) { - recoverRejectedSocket(webSocket) - break - } - messageQueue.poll() - markQueuedMessageSent(msg) - } - triggerReplay() - } - disconnectIfIdleInBackground() - } - - override fun onMessage( - webSocket: WebSocket, - text: String, - ) { - if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return - if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text)) - lastPongTimestamp = monotonicTimeMs() - // Resolve any in-flight `request()` awaiting this RPC result/error - // (issue #526) before fanning the parsed event out to collectors. - val event = - try { - val rpc = OkHttpProvider.json.decodeFromString(text) - // Resolve pending request() calls with the RAW JsonElement from - // rpc.result, BEFORE EventParser.parse() converts it via toAny(). - // This preserves integer/float type fidelity needed by callers - // that re-deserialize the result into typed data classes. - val rpcId = rpc.id - if (rpcId != null && rpc.error == null && rpc.result != null) { - synchronized(outboundLock) { pendingPromptSubmits.remove(rpcId) } - removeQueuedMessage(rpcId) - resolvePending(rpcId, rpc.result, null) - } - - // Resume/replay responses carry still-open server requests - // separately from the event ring. Re-deliver them through the - // same generic path before exposing the RPC result. - @Suppress("UNCHECKED_CAST") - val openRequests = - (rpc.result?.toAny() as? Map<*, *>)?.get("open_requests") as? List> - openRequests?.forEach { openRequest -> - val openId = openRequest["id"] as? String ?: return@forEach - val method = openRequest["method"] as? String ?: return@forEach - - @Suppress("UNCHECKED_CAST") - val params = openRequest["params"] as? Map ?: emptyMap() - parsedEvents.tryEmit(WsEvent.ServerRequest(openId, method, params, replayed = true)) - } - - if (rpc.id == null) { - // Issue #1163: inspect scalars, not a throwaway copy of the whole params tree. - val sid = rpc.params?.eventSessionId() - val seq = ((rpc.params?.get("seq") as? JsonPrimitive)?.toAny() as? Number)?.toInt() - - if (!sid.isNullOrBlank() && seq != null) { - val prev = lastSeenSeq[sid] ?: 0 - if (seq <= prev) { - // Frame is already at or behind watermark — drop duplicate - return - } - val parsed = EventParser.parse(rpc, text) - val finalEvent = - if (parsed is WsEvent.MessageComplete) { - parsed.copy( - storedSessionId = ActiveSessionHolder.resolveStoredSessionId(parsed.sessionId), - ) - } else { - parsed - } - if (replayInFlight && replayHold.containsKey(sid)) { - replayHold[sid]?.add(seq to finalEvent) - return - } - lastSeenSeq[sid] = seq - parsedEvents.tryEmit(finalEvent) - return - } - } - - val parsed = EventParser.parse(rpc, text) - if (parsed is WsEvent.MessageComplete) { - parsed.copy( - storedSessionId = ActiveSessionHolder.resolveStoredSessionId(parsed.sessionId), - ) - } else { - parsed - } - } catch (e: Exception) { - Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}") - WsEvent.Unknown(text) - } - if (consumeCapabilityResponse(event)) return - when (event) { - is WsEvent.RpcResult -> { - synchronized(outboundLock) { pendingPromptSubmits.remove(event.id) } - removeQueuedMessage(event.id) - resolvePending(event.id, event.result, null) - } - - is WsEvent.RpcError -> { - synchronized(outboundLock) { - if (pendingPromptSubmits.remove(event.id) && - pendingPromptSubmits.isEmpty() - ) { - pendingReply = false - 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) - } - - else -> {} - } - // tryEmit on a DROP_OLDEST flow only returns false when the - // buffer is full AND no subscriber is draining; with extraBuffer=2048 - // and always-on init collectors this is unreachable in practice. - parsedEvents.tryEmit(event) - } - - override fun onClosing( - webSocket: WebSocket, - code: Int, - reason: String, - ) { - synchronized(outboundLock) { - if (!isCurrent()) { - webSocket.close(code, reason) - return - } - closingSocket = webSocket - // Do NOT log [reason] — it may carry server-side context. - Log.d(TAG, "WebSocket closing: $code") - if (isTerminalAuthClose(code, reason)) { - _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED - } - webSocket.close(code, reason) - } - } - - override fun onClosed( - webSocket: WebSocket, - code: Int, - reason: String, - ) { - synchronized(outboundLock) { - if (!isCurrent()) return - connectionGeneration.incrementAndGet() - closingSocket = null - outboundDrainJob?.cancel() - outboundDrainJob = null - if (HermesWsClient.webSocket === webSocket) HermesWsClient.webSocket = null - // Do NOT log [reason] — it may carry server-side context. The - // reason is still inspected internally to detect auth failures. - Log.i(TAG, "WebSocket closed: $code") - connected.set(false) - ActiveSessionHolder.clear() - stopHealthTracking() - capabilityRequestIds.clear() - if (isTerminalAuthClose(code, reason)) { - _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED - } else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) { - _connectionStatus.value = ConnectionStatus.RECONNECTING - scheduleReconnect() - } - } - } - - override fun onFailure( - webSocket: WebSocket, - t: Throwable, - response: Response?, - ) { - synchronized(outboundLock) { - if (!isCurrent()) return - connectionGeneration.incrementAndGet() - closingSocket = null - outboundDrainJob?.cancel() - outboundDrainJob = null - if (HermesWsClient.webSocket === webSocket) HermesWsClient.webSocket = null - // Log the exception class only — [Throwable.message] can leak URLs - // or headers. The message is still inspected internally for auth - // detection. - Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t) - connected.set(false) - ActiveSessionHolder.clear() - stopHealthTracking() - capabilityRequestIds.clear() - val code = response?.code ?: 0 - if (code == 401 || code == 4401 || code == 4403 || - t.message?.contains("401") == true || - t.message?.contains("4401") == true || - t.message?.contains("4403") == true || - t.message?.contains("unauthorized", ignoreCase = true) == true - ) { - _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED - } else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) { - _connectionStatus.value = ConnectionStatus.RECONNECTING - scheduleReconnect() - } - } - } - } + internal suspend fun fetchReplayForTest() = replayTracker.fetchReplayForTest() @VisibleForTesting - internal fun setConnectionStatusForTest(status: ConnectionStatus) { - _connectionStatus.value = status - } + internal fun probeLivenessOnTransportChange( + timeoutMs: Long = ConnectionHealth.LIVENESS_PROBE_TIMEOUT_MS, + onFailureAction: () -> Unit = { transport.cancelSocket() }, + ) = connectionHealth.probeLivenessOnTransportChange(timeoutMs, onFailureAction) + + @VisibleForTesting + internal fun setLastLatencyForTest(latency: Long?) = connectionHealth.setLastLatencyForTest(latency) + + @VisibleForTesting + internal fun forceHealthCheckForTest(staleMillis: Long) = connectionHealth.forceHealthCheckForTest(staleMillis) + + @VisibleForTesting + internal fun setReconnectBackoffForTest(initialMillis: Long) = transport.setReconnectBackoffForTest(initialMillis) + + @VisibleForTesting + internal fun setConnectionStatusForTest(status: ConnectionStatus) = transport.setConnectionStatusForTest(status) + + @VisibleForTesting + internal val connectedForTest: AtomicBoolean + get() = transport.connectedForTest + + @VisibleForTesting + internal val intentionalCloseForTest: AtomicBoolean + get() = transport.intentionalCloseForTest + + @VisibleForTesting + internal val connectionGenerationForTest: AtomicInteger + get() = transport.connectionGenerationForTest + + @VisibleForTesting + internal val messageQueueForTest: ConcurrentLinkedQueue + get() = transport.messageQueueForTest + + @VisibleForTesting + internal val outboundLockForTest: Any + get() = transport.outboundLockForTest + + @VisibleForTesting + internal val acceptQueuedMessagesForTest: AtomicBoolean + get() = transport.acceptQueuedMessagesForTest + + @VisibleForTesting + internal val connectionStatusForTest: MutableStateFlow + get() = transport.connectionStatusForTest + + @VisibleForTesting + internal var webSocketForTest: WebSocket? by transport::webSocketForTest + + @VisibleForTesting + internal var currentBackoffForTest: Long by transport::currentBackoffForTest + + @VisibleForTesting + internal var reconnectJobForTest: Job? by transport::reconnectJobForTest + + @VisibleForTesting + internal var outboundDrainJobForTest: Job? by transport::outboundDrainJobForTest + + @VisibleForTesting + internal fun scheduleReconnectForTest() = transport.scheduleReconnectForTest() + + @VisibleForTesting + internal fun createListenerForTest(generation: Int): WebSocketListener = transport.createListenerForTest(generation) + + @VisibleForTesting + internal fun pendingCallIdsForTest(): Set = rpcChannel.pendingCallIdsForTest() + + @VisibleForTesting + internal fun pendingCallTimeoutForTest(id: String): Job? = rpcChannel.pendingCallTimeoutForTest(id) @VisibleForTesting internal fun formatSafeOutgoingFrameLog( @@ -1647,47 +389,11 @@ object HermesWsClient { paramsKeys: Collection?, byteLength: Int, queued: Boolean = false, - ): String { - val prefix = if (queued) "→ (queued)" else "→" - val keys = paramsKeys?.joinToString(",", prefix = "[", postfix = "]") ?: "[]" - return "$prefix id=$id method=$method paramsKeys=$keys bytes=$byteLength" - } + ): String = transport.formatSafeOutgoingFrameLog(id, method, paramsKeys, byteLength, queued) @VisibleForTesting - internal fun formatSafeQueuedFrameLog(msg: String): String = - try { - val req = OkHttpProvider.json.decodeFromString(msg) - formatSafeOutgoingFrameLog(req.id, req.method, req.params.keys, msg.length, queued = true) - } catch (_: Throwable) { - "→ (queued) frame bytes=${msg.length}" - } + internal fun formatSafeQueuedFrameLog(msg: String): String = transport.formatSafeQueuedFrameLog(msg) @VisibleForTesting - internal fun formatSafeIncomingFrameLog(text: String): String = - try { - val element = OkHttpProvider.json.parseToJsonElement(text) - if (element is JsonObject) { - val id = (element["id"] as? JsonPrimitive)?.content - val method = (element["method"] as? JsonPrimitive)?.content - val hasResult = element.containsKey("result") - val resultKeys = - (element["result"] as? JsonObject)?.keys?.joinToString( - ",", - prefix = "[", - postfix = "]", - ) - val errorObj = element["error"] as? JsonObject - val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.content - val sb = StringBuilder("← ") - if (id != null) sb.append("id=").append(id).append(" ") - if (method != null) sb.append("method=").append(method).append(" ") - if (hasResult) sb.append("result=").append(resultKeys ?: "present").append(" ") - if (errorCode != null) sb.append("errorCode=").append(errorCode).append(" ") - sb.append("bytes=").append(text.length).toString() - } else { - "← non-object frame bytes=${text.length}" - } - } catch (_: Throwable) { - "← frame bytes=${text.length}" - } + internal fun formatSafeIncomingFrameLog(text: String): String = transport.formatSafeIncomingFrameLog(text) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ReconnectPolicy.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ReconnectPolicy.kt new file mode 100644 index 00000000..ae7fa4e4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ReconnectPolicy.kt @@ -0,0 +1,39 @@ +package com.m57.hermescontrol.data.ws + +internal class ReconnectPolicy( + private val random: () -> Double = { Math.random() }, +) { + @Volatile + var currentBackoff: Long = INITIAL_BACKOFF_MS + private var initialBackoffMs: Long = INITIAL_BACKOFF_MS + private var testBackoffOverrideMs: Long? = null + + fun nextDelayMs(): Long { + val override = testBackoffOverrideMs + if (override != null) return override + val ceiling = currentBackoff + if (ceiling <= 0L) return 0L + return (random() * ceiling).toLong().coerceAtLeast(BACKOFF_BASE_MS).coerceAtMost(ceiling) + } + + fun advance() { + currentBackoff = (currentBackoff * BACKOFF_MULTIPLIER).toLong().coerceAtMost(MAX_BACKOFF_MS) + } + + fun reset() { + currentBackoff = initialBackoffMs + } + + fun setBackoffForTest(initialMillis: Long) { + initialBackoffMs = initialMillis + currentBackoff = initialMillis + testBackoffOverrideMs = initialMillis + } + + companion object { + private const val INITIAL_BACKOFF_MS = 1_000L + private const val BACKOFF_BASE_MS = 300L + private const val MAX_BACKOFF_MS = 30_000L + private const val BACKOFF_MULTIPLIER = 2.0 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ReplayTracker.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ReplayTracker.kt new file mode 100644 index 00000000..7cede6ac --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ReplayTracker.kt @@ -0,0 +1,245 @@ +package com.m57.hermescontrol.data.ws + +import androidx.annotation.VisibleForTesting +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks session event sequence watermarks and orchestrates event replay on reconnect. + * + * Extracted from [HermesWsClient] (issue #1016, issue #1376). + */ +internal class ReplayTracker( + private val scope: CoroutineScope, + private val fetchEventsSince: suspend (SessionEventsSinceParams) -> SessionEventsSinceResult, + private val emitEvent: (WsEvent) -> Unit, + private val resolveStoredSessionId: (String?) -> String? = { it }, + private val onReplayFailure: (String, Exception) -> Unit = { _, _ -> }, +) { + private val lastSeenSeq = ConcurrentHashMap() + + @Volatile + private var replayEpoch: String? = null + + @Volatile + private var replayInFlight: Boolean = false + + private val replayHold = ConcurrentHashMap>>() + + @Volatile + private var replayJob: Job? = null + + /** + * Synchronously arms the hold buffers for all known sessions and launches replay in [scope]. + * Arming synchronously ensures racing live frames are held instead of advancing watermarks. + */ + fun triggerReplay() { + if (lastSeenSeq.isEmpty() || replayInFlight) return + replayInFlight = true + val sessionsToReplay = lastSeenSeq.keys().toList() + for (sid in sessionsToReplay) { + replayHold[sid] = Collections.synchronizedList(mutableListOf()) + } + replayJob?.cancel() + replayJob = + scope.launch { + fetchReplay(sessionsToReplay) + } + } + + /** + * Updates replay epoch from gateway.ready. + * When [epoch] is non-empty and changes from a previous non-null value, resets sequence watermarks. + */ + fun updateEpoch(epoch: String?) { + if (!epoch.isNullOrEmpty()) { + if (replayEpoch != null && replayEpoch != epoch) { + lastSeenSeq.clear() + } + replayEpoch = epoch + } + } + + /** + * Checks if an incoming event is a duplicate against the current watermark for [sessionId]. + * Returns true if [seq] <= watermark. + */ + fun isDuplicate( + sessionId: String, + seq: Int, + ): Boolean { + val prev = lastSeenSeq[sessionId] ?: 0 + return seq <= prev + } + + /** + * Processes an incoming live event with sequence tracking. + * Evaluates [eventFactory] only after checking for duplicates. + * If duplicate, drops it without calling [eventFactory]. + * If replay is in flight for [sessionId], buffers it in replayHold. + * Otherwise advances watermark and emits the event. + */ + fun acceptLiveEvent( + sessionId: String, + seq: Int, + eventFactory: () -> WsEvent, + ) { + if (isDuplicate(sessionId, seq)) { + return + } + val finalEvent = decorateEvent(eventFactory()) + if (replayInFlight && replayHold.containsKey(sessionId)) { + replayHold[sessionId]?.add(seq to finalEvent) + return + } + lastSeenSeq[sessionId] = seq + emitEvent(finalEvent) + } + + /** + * Processes an incoming live event with sequence tracking. + * If duplicate, drops it. + * If replay is in flight for [sessionId], buffers it in replayHold. + * Otherwise advances watermark and emits the event. + */ + fun acceptLiveEvent( + sessionId: String, + seq: Int, + event: WsEvent, + ) { + acceptLiveEvent(sessionId, seq) { event } + } + + private fun decorateEvent(event: WsEvent): WsEvent = + if (event is WsEvent.MessageComplete) { + event.copy( + storedSessionId = resolveStoredSessionId(event.sessionId), + ) + } else { + event + } + + private suspend fun fetchReplay(sessionsToReplay: List = lastSeenSeq.keys().toList()) { + try { + for (sid in sessionsToReplay) { + val lastSeen = lastSeenSeq[sid] ?: continue + try { + val res = + fetchEventsSince( + SessionEventsSinceParams(sessionId = sid, lastSeen = lastSeen), + ) + + val epoch = res.epoch + val epochChanged = + !epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch + val truncated = res.truncated == true + val latestSeq = res.latestSeq + + if (epochChanged) replayEpoch = epoch + + if (epochChanged || truncated) { + // Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted). + // Partial replay would silently hole the transcript — fast-forward watermark + // and request full transcript resync. + if (epochChanged) lastSeenSeq.clear() + if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq + emitEvent(WsEvent.TranscriptResyncRequired(sid)) + continue + } + val eventsList = res.events + if (eventsList != null) { + for (element in eventsList) { + @Suppress("UNCHECKED_CAST") + val eventMap = (element as? JsonObject)?.toAny() as? Map ?: continue + val eventSeq = (eventMap["seq"] as? Number)?.toInt() + val eventSid = (eventMap["session_id"] as? String) ?: sid + if (eventSeq != null && eventSid.isNotEmpty()) { + val prev = lastSeenSeq[eventSid] ?: 0 + if (eventSeq <= prev) continue + lastSeenSeq[eventSid] = eventSeq + } + val parsedEvent = EventParser.parseParams(eventMap) + val finalEvent = decorateEvent(parsedEvent) + emitEvent(finalEvent) + } + } + } catch (e: Exception) { + onReplayFailure(sid, e) + emitEvent(WsEvent.TranscriptResyncRequired(sid)) + } + } + } finally { + withContext(NonCancellable) { + for (sid in sessionsToReplay) { + val held = replayHold.remove(sid) + if (held != null) { + synchronized(held) { + for ((heldSeq, heldEvent) in held) { + if (heldSeq != null) { + val prev = lastSeenSeq[sid] ?: 0 + if (heldSeq <= prev) continue + lastSeenSeq[sid] = heldSeq + } + emitEvent(heldEvent) + } + } + } + } + replayInFlight = false + } + } + } + + /** + * Complete teardown matching disconnect (issue #1016). + * Cancels any active replay job and clears all holds, watermarks, and epoch. + */ + fun clear() { + lastSeenSeq.clear() + replayHold.clear() + replayEpoch = null + replayJob?.cancel() + replayJob = null + replayInFlight = false + } + + @VisibleForTesting + internal fun getSeqWatermarks(): Map = HashMap(lastSeenSeq) + + @VisibleForTesting + internal fun setSeqWatermark( + sessionId: String, + seq: Int, + ) { + lastSeenSeq[sessionId] = seq + } + + @VisibleForTesting + internal fun clearSeqWatermarks() { + lastSeenSeq.clear() + replayHold.clear() + replayEpoch = null + replayInFlight = false + } + + @VisibleForTesting + internal fun isReplayInFlight(): Boolean = replayInFlight + + @VisibleForTesting + internal fun setReplayEpochForTest(epoch: String?) { + replayEpoch = epoch + } + + @VisibleForTesting + internal suspend fun fetchReplayForTest() { + fetchReplay() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/RpcChannel.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/RpcChannel.kt new file mode 100644 index 00000000..6f8cfa5d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/RpcChannel.kt @@ -0,0 +1,353 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.ws.contract.RpcMethod +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +/** + * Socket-independent JSON-RPC 2.0 request correlation and response dispatcher. + * Manages request IDs, in-flight pending calls, timeouts, cancellation cleanup, + * and server response frame formatting. + */ +internal class RpcChannel( + private val scope: CoroutineScope, + private val json: Json, + private val sendRequest: (method: String, params: Map, onSent: ((String) -> Unit)?) -> String, + private val sendFrame: (String) -> Boolean, + private val removeQueuedMessage: (String) -> Unit, + private val onIdleCheck: () -> Unit, + private val logRejected: (method: String, id: String) -> Unit = { _, _ -> }, + private val outboundLock: Any = Any(), + private val onResult: (String) -> Unit = {}, + private val onError: (String) -> Unit = {}, + private val resolveStoredSessionId: (String?) -> String? = { it }, + private val logParseFailure: (Exception) -> Unit = {}, + private val decorateParams: (String, Map) -> Map = { _, p -> p }, +) { + /** A single in-flight [request] awaiting its RPC result/error. */ + private data class PendingCall( + val method: String, + val deferred: CompletableDeferred, + var timeoutJob: Job? = null, + val suppressErrorEvent: Boolean = false, + ) + + private val requestId = AtomicInteger(0) + private val pendingCalls = ConcurrentHashMap() + private val capabilityRequestIds = ConcurrentHashMap.newKeySet() + + fun nextId(): String = requestId.incrementAndGet().toString() + + fun hasPendingCalls(): Boolean = pendingCalls.isNotEmpty() + + fun pendingCallIdsForTest(): Set = pendingCalls.keys.toSet() + + fun pendingCallTimeoutForTest(id: String): Job? = pendingCalls[id]?.timeoutJob + + fun isErrorEventSuppressed(id: String): Boolean = pendingCalls[id]?.suppressErrorEvent == true + + fun registerCapabilityRequest(id: String) { + capabilityRequestIds.add(id) + } + + fun clearCapabilityRequests() { + capabilityRequestIds.clear() + } + + fun consumeCapabilityResponse(event: WsEvent): Boolean { + val id = + when (event) { + is WsEvent.RpcResult -> event.id + is WsEvent.RpcError -> event.id + else -> return false + } + val isCapabilityResponse = + synchronized(outboundLock) { + capabilityRequestIds.remove(id) + } + if (!isCapabilityResponse) return false + + removeQueuedMessage(id) + return true + } + + fun createRequest( + method: String, + params: Map, + ): JsonRpcRequest { + val id = nextId() + val decorated = decorateParams(method, params) + return JsonRpcRequest( + id = id, + method = method, + params = decorated.mapValues { it.value.toJsonElement() }, + ) + } + + fun encodeRequest(request: JsonRpcRequest): String = json.encodeToString(request) + + /** + * Send a JSON-RPC request that expects a result and returns a [CompletableDeferred] for it. + */ + fun request( + method: String, + params: Map = emptyMap(), + timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS, + suppressErrorEvent: Boolean = false, + ): CompletableDeferred { + val deferred = CompletableDeferred() + val id = + sendRequest(method, params) { reqId -> + pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent) + } + deferred.invokeOnCompletion { cause -> + if (cause is CancellationException) { + pendingCalls.remove(id)?.let { call -> + call.timeoutJob?.cancel() + removeQueuedMessage(id) + onIdleCheck() + } + } + } + // Arm the per-request timeout (fires if the server never answers). + pendingCalls[id]?.timeoutJob = + scope.launch { + delay(timeoutMs) + resolvePending(id, null, JsonRpcError(-1, "Request timed out: $method")) + } + return deferred + } + + /** + * Non-suspending typed twin of [request] for callers that must enqueue inside a + * non-suspending owner check and await the raw result themselves. + */ + fun requestTyped( + method: RpcMethod, + params: P, + timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS, + suppressErrorEvent: Boolean = false, + ): CompletableDeferred = request(method.name, encodeParams(method, params), timeoutMs, suppressErrorEvent) + + /** + * Send a typed JSON-RPC request and await its deserialized result. + */ + suspend fun call( + method: RpcMethod, + params: P, + timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS, + suppressErrorEvent: Boolean = false, + ): R = awaitResult(method, requestTyped(method, params, timeoutMs, suppressErrorEvent)) + + suspend fun awaitResult( + method: RpcMethod, + deferred: CompletableDeferred, + ): R { + val result = + try { + deferred.await() + } catch (e: CancellationException) { + deferred.cancel(e) + throw e + } + val element = result.toJsonElement() + return json.decodeFromJsonElement(method.result, element) + } + + fun encodeParams( + method: RpcMethod, + params: P, + ): JsonObject { + val encoded = json.encodeToJsonElement(method.params, params) + require(encoded is JsonObject) { + "RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}" + } + return encoded + } + + /** Complete (or fail) a single pending call and cancel its timer. */ + fun resolvePending( + id: String, + result: Any?, + error: JsonRpcError?, + ) { + val call = pendingCalls.remove(id) ?: return + removeQueuedMessage(id) + call.timeoutJob?.cancel() + if (error != null) { + call.deferred.completeExceptionally( + HermesWsClient.HermesRpcException( + message = error.message, + code = error.code, + data = error.data, + ), + ) + } else { + call.deferred.complete(result) + } + onIdleCheck() + } + + /** + * Fail and clear every in-flight [request]. + */ + fun rejectAllPending( + error: HermesWsClient.HermesRpcException = + HermesWsClient.HermesRpcException("Connection lost — request cancelled"), + ) { + if (pendingCalls.isEmpty()) return + val snapshot = pendingCalls.toList() + pendingCalls.clear() + for ((id, call) in snapshot) { + removeQueuedMessage(id) + logRejected(call.method, id) + call.timeoutJob?.cancel() + call.deferred.completeExceptionally(error) + } + } + + /** + * Answer a gateway-originated JSON-RPC request. + */ + fun respondToServerRequest( + id: String, + result: JsonElement, + ): Boolean { + if (id.isBlank()) return false + val frameJson = + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id) + put("result", result) + }.toString() + return sendFrame(frameJson) + } + + /** Answer a gateway-originated request with a standard JSON-RPC error. */ + fun respondToServerRequestError( + id: String, + code: Int, + message: String, + ): Boolean { + if (id.isBlank()) return false + val frameJson = + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id) + put( + "error", + buildJsonObject { + put("code", code) + put("message", message) + }, + ) + }.toString() + return sendFrame(frameJson) + } + + /** + * Handles an incoming WebSocket text frame: JSON decoding, request correlation, + * server request dispatch, sequence deduplication via [replayTracker], and event emission. + */ + fun handleIncomingFrame( + text: String, + replayTracker: ReplayTracker, + emitEvent: (WsEvent) -> Unit, + ) { + val event = + try { + val rpc = json.decodeFromString(text) + // Resolve pending request() calls with the RAW JsonElement from + // rpc.result, BEFORE EventParser.parse() converts it via toAny(). + // This preserves integer/float type fidelity needed by callers + // that re-deserialize the result into typed data classes. + val rpcId = rpc.id + if (rpcId != null && rpc.error == null && rpc.result != null) { + onResult(rpcId) + removeQueuedMessage(rpcId) + resolvePending(rpcId, rpc.result, null) + } + + // Resume/replay responses carry still-open server requests + // separately from the event ring. Re-deliver them through the + // same generic path before exposing the RPC result. + @Suppress("UNCHECKED_CAST") + val openRequests = + (rpc.result?.toAny() as? Map<*, *>)?.get("open_requests") as? List> + openRequests?.forEach { openRequest -> + val openId = openRequest["id"] as? String ?: return@forEach + val method = openRequest["method"] as? String ?: return@forEach + + @Suppress("UNCHECKED_CAST") + val params = openRequest["params"] as? Map ?: emptyMap() + emitEvent(WsEvent.ServerRequest(openId, method, params, replayed = true)) + } + + if (rpc.id == null) { + // Issue #1163: inspect scalars, not a throwaway copy of the whole params tree. + val sid = rpc.params?.eventSessionId() + val seq = ((rpc.params?.get("seq") as? JsonPrimitive)?.toAny() as? Number)?.toInt() + + if (!sid.isNullOrBlank() && seq != null) { + replayTracker.acceptLiveEvent(sid, seq) { + EventParser.parse(rpc, text) + } + return + } + } + + val parsed = EventParser.parse(rpc, text) + if (parsed is WsEvent.MessageComplete) { + parsed.copy( + storedSessionId = resolveStoredSessionId(parsed.sessionId), + ) + } else { + parsed + } + } catch (e: Exception) { + logParseFailure(e) + WsEvent.Unknown(text) + } + if (consumeCapabilityResponse(event)) return + when (event) { + is WsEvent.RpcResult -> { + onResult(event.id) + removeQueuedMessage(event.id) + resolvePending(event.id, event.result, null) + } + + is WsEvent.RpcError -> { + onError(event.id) + 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) + } + + else -> {} + } + emitEvent(event) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsTransport.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsTransport.kt new file mode 100644 index 00000000..b425ca4d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsTransport.kt @@ -0,0 +1,912 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.m57.hermescontrol.BuildConfig +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.NetworkMonitor +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.await +import com.m57.hermescontrol.data.session.ActiveSessionHolder +import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.utf8Size +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +/** + * Socket lifecycle, authentication, outbound queueing, and network transport management. + * + * Extracted from [HermesWsClient] (issue #1376). + */ +internal class WsTransport( + private val scope: CoroutineScope, + private val outboundLock: Any, + private val reconnectPolicy: ReconnectPolicy, + private val connectionHealth: ConnectionHealth, + private val replayTracker: ReplayTracker, + private val rpcChannel: RpcChannel, + private val onRawMessage: (String) -> Unit, +) { + companion object { + private const val TAG = "HermesWsClient" + private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024 + private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L + } + + private val connectionGeneration = AtomicInteger(0) + private val connected = AtomicBoolean(false) + private val intentionalClose = AtomicBoolean(false) + private val acceptQueuedMessages = AtomicBoolean(true) + private val appInForeground = AtomicBoolean(true) + private val externalActivityConnectionLease = AtomicBoolean(false) + private val backgroundConnectionLease = AtomicBoolean(false) + private val messageQueue = ConcurrentLinkedQueue() + private val queuedMessagesById = ConcurrentHashMap() + + @Volatile + private var webSocket: WebSocket? = null + + @Volatile + private var closingSocket: WebSocket? = null + + @Volatile + private var reconnectJob: Job? = null + + @Volatile + private var outboundDrainJob: Job? = null + + private val networkCollectorStarted = AtomicBoolean(false) + + private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED) + val connectionStatus: StateFlow = _connectionStatus.asStateFlow() + + @Volatile + var pendingReply: Boolean = false + internal set + + private val pendingPromptSubmits = ConcurrentHashMap.newKeySet() + + val isConnected: Boolean get() = connected.get() + + fun setAppForeground(foreground: Boolean) { + appInForeground.set(foreground) + if (!foreground) { + disconnectIfIdleInBackground() + return + } + connectionHealth.probeLivenessOnWake() + } + + fun acquireExternalActivityConnectionLease() { + externalActivityConnectionLease.set(true) + } + + fun releaseExternalActivityConnectionLease() { + externalActivityConnectionLease.set(false) + disconnectIfIdleInBackground() + } + + fun acquireBackgroundConnectionLease() { + backgroundConnectionLease.set(true) + } + + fun releaseBackgroundConnectionLease() { + backgroundConnectionLease.set(false) + disconnectIfIdleInBackground() + } + + internal fun hasBackgroundConnectionLease(): Boolean = backgroundConnectionLease.get() + + fun disconnectIfIdleInBackground() { + synchronized(outboundLock) { + if (!appInForeground.get() && + !externalActivityConnectionLease.get() && + !backgroundConnectionLease.get() && + !pendingReply && + !rpcChannel.hasPendingCalls() && + messageQueue.isEmpty() + ) { + disconnect() + } + } + } + + fun connect() { + if (networkCollectorStarted.compareAndSet(false, true)) { + scope.launch { + NetworkMonitor.networkChanges.collect { networkAvailable -> + reconnectForNetworkChange(networkAvailable) + } + } + scope.launch { + NetworkMonitor.transportChanges.collect { + connectionHealth.probeLivenessOnTransportChange( + onFailureAction = { cancelSocket() }, + ) + } + } + } + var socketToCancel: WebSocket? = null + val shouldOpen = + synchronized(outboundLock) { + acceptQueuedMessages.set(true) + if (_connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) { + connectionGeneration.incrementAndGet() + reconnectJob?.cancel() + reconnectJob = null + outboundDrainJob?.cancel() + outboundDrainJob = null + socketToCancel = webSocket + webSocket = null + closingSocket = null + connected.set(false) + connectionHealth.stop() + } + if (connected.get()) { + Log.d(TAG, "Already connected — skipping") + return@synchronized false + } + if (_connectionStatus.value == ConnectionStatus.CONNECTING || + _connectionStatus.value == ConnectionStatus.RECONNECTING + ) { + Log.d(TAG, "Connection already in flight (${_connectionStatus.value}) — skipping") + return@synchronized false + } + // An explicit connect follows successful authentication and is + // therefore allowed to leave the terminal AUTH_EXPIRED state. + intentionalClose.set(false) + _connectionStatus.value = ConnectionStatus.CONNECTING + reconnectPolicy.reset() + true + } + if (socketToCancel != null) { + rpcChannel.rejectAllPending() + socketToCancel.cancel() + } + if (shouldOpen) openSocket() + } + + fun reconnectForNetworkChange( + networkAvailable: Boolean = NetworkMonitor.isConnected.value, + openReplacement: () -> Unit = ::openSocket, + ) { + val socketToCancel: WebSocket? + val shouldOpen: Boolean + synchronized(outboundLock) { + if (intentionalClose.get() || _connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) { + return + } + Log.d(TAG, "Default network changed — reconnecting WebSocket") + reconnectPolicy.reset() + reconnectJob?.cancel() + reconnectJob = null + outboundDrainJob?.cancel() + outboundDrainJob = null + connectionGeneration.incrementAndGet() + socketToCancel = webSocket + webSocket = null + closingSocket = null + connected.set(false) + connectionHealth.stop() + shouldOpen = networkAvailable && AuthManager.isAutoReconnect() + _connectionStatus.value = + when { + !networkAvailable -> ConnectionStatus.NO_NETWORK + shouldOpen -> ConnectionStatus.RECONNECTING + else -> ConnectionStatus.DISCONNECTED + } + rpcChannel.rejectAllPending() + } + socketToCancel?.cancel() + if (shouldOpen) openReplacement() + } + + suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean { + val isGated = + try { + AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" + } catch (_: IllegalStateException) { + // serverStore not initialized yet (transient early call); treat + // as loopback so a stale-token refresh still runs. Log so a real + // misconfiguration isn't silently swallowed. + Log.w(TAG, "serverStore uninitialized during WS handshake; assuming non-gated") + false + } + if (!isGated) { + // The loopback dashboard token is regenerated on every server + // restart. Refresh it before each WebSocket handshake so automatic + // reconnect does not get stuck in AUTH_EXPIRED with a stale token. + val token = + runCatching { + DashboardSessionTokenRefresher.refreshAsync() + }.getOrNull() + synchronized(outboundLock) { + if (isCurrentGeneration(generation) && token != null) { + runCatching { AuthManager.setToken(token) } + } + } + return true + } + + val ticketResult = requestWsTicket() + if (!ticketResult.ticket.isNullOrBlank()) { + synchronized(outboundLock) { + if (!isCurrentGeneration(generation)) return false + AuthManager.setToken(ticketResult.ticket) + } + if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed") + return true + } + val status = + when { + ticketResult.exception -> ConnectionStatus.RECONNECTING + ticketResult.httpCode == 401 || ticketResult.httpCode == 403 -> ConnectionStatus.AUTH_EXPIRED + ticketResult.httpCode == 408 || ticketResult.httpCode == 429 -> ConnectionStatus.RECONNECTING + ticketResult.httpCode != null && ticketResult.httpCode >= 500 -> ConnectionStatus.RECONNECTING + else -> ConnectionStatus.DISCONNECTED + } + return handleWsTicketRefreshFailure(status, generation) + } + + suspend fun mintWsTicket(): String? { + val isGated = + try { + AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" + } catch (_: IllegalStateException) { + false + } + if (!isGated) { + DashboardSessionTokenRefresher.refreshAsync() + return AuthManager.getToken() + } + return requestWsTicket().ticket + } + + private data class TicketRequestResult( + val ticket: String?, + val httpCode: Int?, + val exception: Boolean = false, + ) + + private suspend fun requestWsTicket(): TicketRequestResult { + try { + val client = OkHttpProvider.probe + val request = + Request + .Builder() + .url(AuthManager.endpointForBuild().resolve("api/auth/ws-ticket").toString()) + .post("{}".toRequestBody()) + .build() + + val (code, body) = + withContext(Dispatchers.IO) { + if (CookieManager.isInitialized()) { + CookieManager.useStore(CookieManager.cookieJar.currentServer()) + } + client.newCall(request).await().use { resp -> + resp.code to resp.body.string() + } + } + + if (code in 200..299) { + // Real JSON decode — the ticket is base64url and may contain + // characters a naive [^"]+ regex cannot extract (see + // testGatedMode_parsesEscapedTicketFromRealJsonShape). + val parsed = + runCatching { + OkHttpProvider.json.decodeFromString(body) + }.getOrNull() + val ticket = parsed?.ticket + if (!ticket.isNullOrBlank()) { + return TicketRequestResult(ticket, null) + } + Log.w(TAG, "WS ticket mint failed: unparseable ticket response") + } else { + Log.w(TAG, "WS ticket mint failed: HTTP $code") + return TicketRequestResult(null, code) + } + } catch (e: Exception) { + // Include the stack: the exception class alone (e.g. + // NetworkOnMainThreadException) can't show WHICH caller on what + // thread tripped the mint (main-scope kanban events connect vs + // the WS reconnect path). + Log.w(TAG, "WS ticket mint failed: ${e.javaClass.simpleName}", e) + return TicketRequestResult(null, null, exception = true) + } + return TicketRequestResult(null, null) + } + + private fun handleWsTicketRefreshFailure( + status: ConnectionStatus, + generation: Int, + ): Boolean { + synchronized(outboundLock) { + if (!isCurrentGeneration(generation)) return false + _connectionStatus.value = status + if (status == ConnectionStatus.RECONNECTING) scheduleReconnect() + } + return false + } + + fun disconnect(clearPendingMessages: Boolean = false) { + ActiveSessionHolder.clear() + synchronized(outboundLock) { + intentionalClose.set(true) + acceptQueuedMessages.set(!clearPendingMessages) + connectionGeneration.incrementAndGet() + reconnectJob?.cancel() + reconnectJob = null + outboundDrainJob?.cancel() + outboundDrainJob = null + connectionHealth.cancelForegroundProbe() + connectionHealth.stop() + webSocket?.close(1000, "Client closed") + webSocket = null + closingSocket = null + rpcChannel.clearCapabilityRequests() + if (clearPendingMessages) { + backgroundConnectionLease.set(false) + messageQueue.clear() + queuedMessagesById.clear() + pendingPromptSubmits.clear() + pendingReply = false + replayTracker.clear() + } + connected.set(false) + _connectionStatus.value = ConnectionStatus.DISCONNECTED + } + } + + fun cancelSocket() { + synchronized(outboundLock) { + if (connected.get()) webSocket?.cancel() + } + } + + fun sendDirectFrame(frameJson: String): Boolean = + synchronized(outboundLock) { + val ws = webSocket + if (ws == null || !connected.get()) false else ws.send(frameJson) + } + + fun send( + request: JsonRpcRequest, + onSent: ((String) -> Unit)? = null, + queueIfDisconnected: Boolean = true, + ): String { + val id = request.id + val method = request.method + val json = rpcChannel.encodeRequest(request) + if (BuildConfig.DEBUG) { + Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false)) + } + var reconnect = false + synchronized(outboundLock) { + if (method == WsMethods.PROMPT_SUBMIT) { + pendingPromptSubmits.add(id) + pendingReply = true + } + val ws = webSocket + if (ws != null && connected.get()) { + // Never replay send(true): the server may have executed it even if its response is lost. + if (ws.send(json)) { + onSent?.invoke(id) + } else { + if (webSocket !== ws || !acceptQueuedMessages.get()) return@synchronized + if (queueIfDisconnected && isRetryableMessage(json)) { + onSent?.invoke(id) + Log.w(TAG, "WS rejected outgoing message — queuing for reconnect") + queueMessage(id, json) + recoverRejectedSocket(ws) + } else { + Log.w(TAG, "WS rejected oversized outgoing message — not retrying") + } + } + } else if (acceptQueuedMessages.get() && queueIfDisconnected) { + if (isRetryableMessage(json)) { + onSent?.invoke(id) + Log.d(TAG, "WS disconnected — queuing message") + queueMessage(id, json) + reconnect = true + } else { + Log.w(TAG, "WS disconnected with oversized outgoing message — not queueing") + } + } + } + if (reconnect) connect() + return id + } + + fun removeQueuedMessage(id: String) { + synchronized(outboundLock) { + queuedMessagesById.remove(id)?.let(messageQueue::remove) + } + } + + fun onRpcResult(id: String) { + synchronized(outboundLock) { + pendingPromptSubmits.remove(id) + } + } + + fun onRpcError(id: String) { + synchronized(outboundLock) { + if (pendingPromptSubmits.remove(id) && pendingPromptSubmits.isEmpty()) { + pendingReply = false + disconnectIfIdleInBackground() + } + } + } + + fun observeEvent(event: WsEvent) { + when (event) { + is WsEvent.MessageStart, + is WsEvent.MessageToken, + is WsEvent.ThinkingDelta, + is WsEvent.ReasoningDelta, + is WsEvent.ToolStart, + -> { + pendingReply = true + } + + is WsEvent.MessageComplete -> { + pendingReply = false + disconnectIfIdleInBackground() + } + + else -> {} + } + } + + private fun isRetryableMessage(json: String): Boolean { + if (json.length > MAX_OUTBOUND_MESSAGE_BYTES) return false + if (json.length <= MAX_OUTBOUND_MESSAGE_BYTES / 4) return true + return json.utf8Size() <= MAX_OUTBOUND_MESSAGE_BYTES.toLong() + } + + private fun queueMessage( + id: String, + json: String, + ) { + if (queuedMessagesById.putIfAbsent(id, json) == null) messageQueue.add(json) + } + + private fun markQueuedMessageSent(json: String) { + queuedMessagesById.entries.removeIf { it.value == json } + } + + private fun recoverRejectedSocket(ws: WebSocket) { + connected.set(false) + if (closingSocket === ws) return + _connectionStatus.value = ConnectionStatus.RECONNECTING + if (ws.queueSize() == 0L) { + ws.cancel() + scheduleReconnect() + return + } + if (intentionalClose.get()) return + outboundDrainJob?.cancel() + outboundDrainJob = + scope.launch { + delay(OUTBOUND_DRAIN_TIMEOUT_MS) + synchronized(outboundLock) { + if (!connected.get() && !intentionalClose.get() && webSocket === ws) { + ws.cancel() + outboundDrainJob = null + scheduleReconnect() + } + } + } + } + + fun sendMessage( + sessionId: String, + text: String, + onSent: ((String) -> Unit)? = null, + queued: Boolean = false, + ): String = + send( + rpcChannel.createRequest( + method = RpcMethods.PROMPT_SUBMIT.name, + params = + PromptSubmitParams( + sessionId = sessionId, + text = text, + queued = queued.takeIf { it }, + ).let { rpcChannel.encodeParams(RpcMethods.PROMPT_SUBMIT, it) }, + ), + onSent = onSent, + ) + + fun sendRedirect( + sessionId: String, + text: String, + onSent: ((String) -> Unit)? = null, + ): String = + send( + rpcChannel.createRequest( + method = RpcMethods.SESSION_REDIRECT.name, + params = + SessionCorrectionParams( + sessionId = sessionId, + text = text, + ).let { rpcChannel.encodeParams(RpcMethods.SESSION_REDIRECT, it) }, + ), + onSent = onSent, + ) + + internal fun openSocket() { + val generation = + synchronized(outboundLock) { + if (intentionalClose.get()) return + connectionGeneration.incrementAndGet() + } + scope.launch(Dispatchers.IO) { + if (!refreshWsTicketIfNeeded(generation)) { + Log.w(TAG, "Aborting openSocket: WS ticket refresh failed") + return@launch + } + if (!isCurrentGeneration(generation)) return@launch + val url = AuthManager.wsUrl() + val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED") + if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl") + + val request = Request.Builder().url(url).build() + val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation)) + synchronized(outboundLock) { + if (connectionGeneration.get() == generation && !intentionalClose.get()) { + webSocket = newSocket + } else { + newSocket.cancel() + } + } + } + } + + private fun isCurrentGeneration(generation: Int): Boolean = + connectionGeneration.get() == generation && !intentionalClose.get() + + private fun scheduleReconnect() { + synchronized(outboundLock) { + if (intentionalClose.get() || + _connectionStatus.value == ConnectionStatus.AUTH_EXPIRED || + reconnectJob?.isActive == true + ) { + return + } + if (!AuthManager.isAutoReconnect()) { + if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled") + _connectionStatus.value = ConnectionStatus.DISCONNECTED + return + } + if (!NetworkMonitor.isConnected.value) { + Log.d(TAG, "No network available — delaying reconnect scheduling") + _connectionStatus.value = ConnectionStatus.NO_NETWORK + return + } + val reconnectDelay = reconnectPolicy.nextDelayMs() + reconnectPolicy.advance() + if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${reconnectDelay}ms …") + + reconnectJob = + scope.launch { + delay(reconnectDelay) + val owner = currentCoroutineContext()[Job] + val shouldOpen = + synchronized(outboundLock) { + if (reconnectJob !== owner) { + false + } else { + reconnectJob = null + !intentionalClose.get() && + !connected.get() && + _connectionStatus.value != ConnectionStatus.AUTH_EXPIRED + } + } + if (shouldOpen) openSocket() + } + } + } + + private fun isTerminalAuthClose( + code: Int, + reason: String, + ): Boolean = + code == 4401 || code == 4403 || + reason.contains("unauthorized", ignoreCase = true) || + reason.startsWith("auth:", ignoreCase = true) + + private inner class WsListenerImpl( + private val generation: Int, + ) : WebSocketListener() { + private fun isCurrent(): Boolean = isCurrentGeneration(generation) + + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + synchronized(outboundLock) { + if (!isCurrent()) { + webSocket.close(1000, "Superseded") + return + } + this@WsTransport.webSocket = webSocket + closingSocket = null + outboundDrainJob?.cancel() + outboundDrainJob = null + Log.i(TAG, "WebSocket opened") + connected.set(true) + _connectionStatus.value = ConnectionStatus.CONNECTED + reconnectPolicy.reset() + connectionHealth.start() + + while (true) { + val msg = messageQueue.peek() ?: break + if (!isRetryableMessage(msg)) { + Log.w(TAG, "Dropping oversized queued message") + messageQueue.poll() + markQueuedMessageSent(msg) + continue + } + if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg)) + if (!webSocket.send(msg)) { + recoverRejectedSocket(webSocket) + break + } + messageQueue.poll() + markQueuedMessageSent(msg) + } + replayTracker.triggerReplay() + } + disconnectIfIdleInBackground() + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + if (!isCurrent() || this@WsTransport.webSocket !== webSocket) return + if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text)) + connectionHealth.onInboundFrame() + onRawMessage(text) + } + + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + synchronized(outboundLock) { + if (!isCurrent()) { + webSocket.close(code, reason) + return + } + closingSocket = webSocket + // Do NOT log [reason] — it may carry server-side context. + Log.d(TAG, "WebSocket closing: $code") + if (isTerminalAuthClose(code, reason)) { + _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED + } + webSocket.close(code, reason) + } + } + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + synchronized(outboundLock) { + if (!isCurrent()) return + connectionGeneration.incrementAndGet() + closingSocket = null + outboundDrainJob?.cancel() + outboundDrainJob = null + if (this@WsTransport.webSocket === webSocket) this@WsTransport.webSocket = null + // Do NOT log [reason] — it may carry server-side context. The + // reason is still inspected internally to detect auth failures. + Log.i(TAG, "WebSocket closed: $code") + connected.set(false) + ActiveSessionHolder.clear() + connectionHealth.stop() + rpcChannel.clearCapabilityRequests() + if (isTerminalAuthClose(code, reason)) { + _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED + } else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) { + _connectionStatus.value = ConnectionStatus.RECONNECTING + scheduleReconnect() + } + } + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + synchronized(outboundLock) { + if (!isCurrent()) return + connectionGeneration.incrementAndGet() + closingSocket = null + outboundDrainJob?.cancel() + outboundDrainJob = null + if (this@WsTransport.webSocket === webSocket) this@WsTransport.webSocket = null + // Log the exception class only — [Throwable.message] can leak URLs + // or headers. The message is still inspected internally for auth + // detection. + Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t) + connected.set(false) + ActiveSessionHolder.clear() + connectionHealth.stop() + rpcChannel.clearCapabilityRequests() + val code = response?.code ?: 0 + if (code == 401 || code == 4401 || code == 4403 || + t.message?.contains("401") == true || + t.message?.contains("4401") == true || + t.message?.contains("4403") == true || + t.message?.contains("unauthorized", ignoreCase = true) == true + ) { + _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED + } else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) { + _connectionStatus.value = ConnectionStatus.RECONNECTING + scheduleReconnect() + } + } + } + } + + // ── Test seams (issue #1376 extraction support) ────────────────────── + + @VisibleForTesting + internal val connectedForTest: AtomicBoolean + get() = connected + + @VisibleForTesting + internal val intentionalCloseForTest: AtomicBoolean + get() = intentionalClose + + @VisibleForTesting + internal val connectionGenerationForTest: AtomicInteger + get() = connectionGeneration + + @VisibleForTesting + internal val messageQueueForTest: ConcurrentLinkedQueue + get() = messageQueue + + @VisibleForTesting + internal val outboundLockForTest: Any + get() = outboundLock + + @VisibleForTesting + internal val acceptQueuedMessagesForTest: AtomicBoolean + get() = acceptQueuedMessages + + @VisibleForTesting + internal val connectionStatusForTest: MutableStateFlow + get() = _connectionStatus + + @VisibleForTesting + internal var webSocketForTest: WebSocket? + get() = webSocket + set(value) { + webSocket = value + } + + @VisibleForTesting + internal var currentBackoffForTest: Long + get() = synchronized(outboundLock) { reconnectPolicy.currentBackoff } + set(value) { + synchronized(outboundLock) { + reconnectPolicy.currentBackoff = value + } + } + + @VisibleForTesting + internal var reconnectJobForTest: Job? + get() = reconnectJob + set(value) { + reconnectJob = value + } + + @VisibleForTesting + internal var outboundDrainJobForTest: Job? + get() = outboundDrainJob + set(value) { + outboundDrainJob = value + } + + @VisibleForTesting + internal fun setReconnectBackoffForTest(initialMillis: Long) { + synchronized(outboundLock) { + reconnectPolicy.setBackoffForTest(initialMillis) + } + } + + @VisibleForTesting + internal fun scheduleReconnectForTest() { + scheduleReconnect() + } + + @VisibleForTesting + internal fun createListenerForTest(generation: Int): WebSocketListener = WsListenerImpl(generation) + + @VisibleForTesting + internal fun setConnectionStatusForTest(status: ConnectionStatus) { + _connectionStatus.value = status + } + + @VisibleForTesting + internal fun formatSafeOutgoingFrameLog( + id: String, + method: String, + paramsKeys: Collection?, + byteLength: Int, + queued: Boolean = false, + ): String { + val prefix = if (queued) "→ (queued)" else "→" + val keys = paramsKeys?.joinToString(",", prefix = "[", postfix = "]") ?: "[]" + return "$prefix id=$id method=$method paramsKeys=$keys bytes=$byteLength" + } + + @VisibleForTesting + internal fun formatSafeQueuedFrameLog(msg: String): String = + try { + val req = OkHttpProvider.json.decodeFromString(msg) + formatSafeOutgoingFrameLog(req.id, req.method, req.params.keys, msg.length, queued = true) + } catch (_: Throwable) { + "→ (queued) frame bytes=${msg.length}" + } + + @VisibleForTesting + internal fun formatSafeIncomingFrameLog(text: String): String = + try { + val element = OkHttpProvider.json.parseToJsonElement(text) + if (element is JsonObject) { + val id = (element["id"] as? JsonPrimitive)?.content + val method = (element["method"] as? JsonPrimitive)?.content + val hasResult = element.containsKey("result") + val resultKeys = + (element["result"] as? JsonObject)?.keys?.joinToString( + ",", + prefix = "[", + postfix = "]", + ) + val errorObj = element["error"] as? JsonObject + val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.content + val sb = StringBuilder("← ") + if (id != null) sb.append("id=").append(id).append(" ") + if (method != null) sb.append("method=").append(method).append(" ") + if (hasResult) sb.append("result=").append(resultKeys ?: "present").append(" ") + if (errorCode != null) sb.append("errorCode=").append(errorCode).append(" ") + sb.append("bytes=").append(text.length).toString() + } else { + "← non-object frame bytes=${text.length}" + } + } catch (_: Throwable) { + "← frame bytes=${text.length}" + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionHealthTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionHealthTest.kt new file mode 100644 index 00000000..19481076 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionHealthTest.kt @@ -0,0 +1,518 @@ +package com.m57.hermescontrol.data.ws + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectionHealthTest { + private class Fixture( + val scope: CoroutineScope, + var connected: Boolean = true, + var pingDelayMs: Long = 100L, + var pingShouldFail: Boolean = false, + var pendingReplyState: Boolean = false, + var initialTimeMs: Long = 100_000L, + ) { + var currentTimeMs: Long = initialTimeMs + val pingCalls = mutableListOf() + val cancelCalls = AtomicInteger(0) + val logs = mutableListOf() + val debugLogs = mutableListOf() + + val health: ConnectionHealth = + ConnectionHealth( + scope = scope, + isConnected = { connected }, + pingRequest = { timeoutMs -> + pingCalls.add(timeoutMs) + if (pingShouldFail) { + throw IllegalStateException("Timed out after ${timeoutMs}ms") + } + if (pingDelayMs > 0) { + delay(pingDelayMs) + currentTimeMs += pingDelayMs + } + }, + cancelSocket = { + cancelCalls.incrementAndGet() + }, + pendingReply = { pendingReplyState }, + nowMs = { currentTimeMs }, + log = { logs.add(it) }, + debugLog = { debugLogs.add(it) }, + ) + } + + @Test + fun pingClockElapsedAndFlowUpdates() = + runTest { + val fixture = + Fixture( + scope = this, + pingDelayMs = 120L, + initialTimeMs = 1_000L, + ) + try { + assertEquals(1_000L, fixture.currentTimeMs) + val latency = fixture.health.ping(timeoutMs = 5000L) + assertEquals(120L, latency) + assertEquals(120L, fixture.health.lastLatencyMs.value) + assertEquals(1_120L, fixture.health.lastPongTimestamp) + assertEquals(listOf(5000L), fixture.pingCalls) + } finally { + fixture.health.stop() + } + } + + @Test + fun pingFailureLeavesPreviousLatencyUntouched() = + runTest { + val fixture = + Fixture( + scope = this, + pingDelayMs = 50L, + initialTimeMs = 5_000L, + ) + try { + val firstLatency = fixture.health.ping(timeoutMs = 5000L) + assertEquals(50L, firstLatency) + assertEquals(50L, fixture.health.lastLatencyMs.value) + + fixture.pingShouldFail = true + try { + fixture.health.ping(timeoutMs = 2000L) + fail("Expected ping failure") + } catch (expected: IllegalStateException) { + // Expected + } + + // Previous latency state is preserved + assertEquals(50L, fixture.health.lastLatencyMs.value) + } finally { + fixture.health.stop() + } + } + + @Test + fun stopResetsLatencyToNull() = + runTest { + val fixture = + Fixture( + scope = this, + pingDelayMs = 40L, + ) + try { + fixture.health.ping() + assertEquals(40L, fixture.health.lastLatencyMs.value) + + fixture.health.stop() + assertNull(fixture.health.lastLatencyMs.value) + } finally { + fixture.health.stop() + } + } + + @Test + fun onInboundFrameUpdatesPongTimestamp() = + runTest { + val fixture = + Fixture( + scope = this, + initialTimeMs = 10_000L, + ) + try { + assertEquals(0L, fixture.health.lastPongTimestamp) + + fixture.health.onInboundFrame() + assertEquals(10_000L, fixture.health.lastPongTimestamp) + + fixture.currentTimeMs = 25_000L + fixture.health.onInboundFrame() + assertEquals(25_000L, fixture.health.lastPongTimestamp) + } finally { + fixture.health.stop() + } + } + + @Test + fun isHealthyStrictlyEnforcesStaleThresholdOf30000() = + runTest { + val fixture = + Fixture( + scope = this, + connected = true, + initialTimeMs = 50_000L, + ) + try { + fixture.health.onInboundFrame() + assertEquals(50_000L, fixture.health.lastPongTimestamp) + + // now - lastPong < 30000 is healthy + fixture.currentTimeMs = 50_000L + 29_999L + assertTrue(fixture.health.isHealthy) + + // now - lastPong == 30000 is NOT healthy (< 30000 required) + fixture.currentTimeMs = 50_000L + 30_000L + assertFalse(fixture.health.isHealthy) + + // now - lastPong > 30000 is NOT healthy + fixture.currentTimeMs = 50_000L + 30_001L + assertFalse(fixture.health.isHealthy) + } finally { + fixture.health.stop() + } + } + + @Test + fun isHealthyDisconnectedGuard() = + runTest { + val fixture = + Fixture( + scope = this, + connected = true, + initialTimeMs = 10_000L, + ) + try { + fixture.health.onInboundFrame() + assertTrue(fixture.health.isHealthy) + + fixture.connected = false + assertFalse(fixture.health.isHealthy) + } finally { + fixture.health.stop() + } + } + + @Test + fun forceHealthCheckForTestWatchdogStrictThresholdCancelsSocket() = + runTest { + val fixture = + Fixture( + scope = this, + connected = true, + initialTimeMs = 100_000L, + ) + try { + fixture.health.onInboundFrame() + + // Within stale threshold (30000ms), no cancellation + fixture.health.forceHealthCheckForTest(30_000L) + assertEquals(0, fixture.cancelCalls.get()) + + // Exceeding stale threshold (> 30000ms), cancels socket + fixture.health.forceHealthCheckForTest(30_001L) + assertEquals(1, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun periodicHeartbeatCancelsSocketWhenStaleThresholdExceeded() = + runTest { + val fixture = + Fixture( + scope = this, + connected = true, + pingShouldFail = true, + pingDelayMs = 0L, + initialTimeMs = 10_000L, + ) + try { + fixture.health.start() + runCurrent() + fixture.health.onInboundFrame() + + // Advance by heartbeat interval (15000ms), still fresh + advanceTimeBy(15_000L) + fixture.currentTimeMs += 15_000L + runCurrent() + assertEquals(0, fixture.cancelCalls.get()) + + // Advance another heartbeat interval to 30000ms from last inbound frame + advanceTimeBy(15_000L) + fixture.currentTimeMs += 15_000L + runCurrent() + assertEquals(0, fixture.cancelCalls.get()) + + // Now time elapsed is > 30000ms (e.g. 30001ms) + fixture.currentTimeMs += 1L + advanceTimeBy(15_000L) + fixture.currentTimeMs += 15_000L + runCurrent() + assertEquals(1, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun setLastLatencyForTestSetsFlowDirectly() = + runTest { + val fixture = + Fixture( + scope = this, + ) + try { + assertNull(fixture.health.lastLatencyMs.value) + fixture.health.setLastLatencyForTest(75L) + assertEquals(75L, fixture.health.lastLatencyMs.value) + fixture.health.setLastLatencyForTest(null) + assertNull(fixture.health.lastLatencyMs.value) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnTransportChangeSuccess() = + runTest { + val fixture = + Fixture( + scope = this, + pingDelayMs = 25L, + ) + try { + var customActionCalled = false + fixture.health.probeLivenessOnTransportChange( + timeoutMs = 5000L, + onFailureAction = { customActionCalled = true }, + ) + runCurrent() + + assertEquals(listOf(5000L), fixture.pingCalls) + assertFalse(customActionCalled) + assertEquals(0, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnTransportChangeFailureInvokesCustomAction() = + runTest { + val fixture = + Fixture( + scope = this, + pingShouldFail = true, + ) + try { + var customActionCalled = false + fixture.health.probeLivenessOnTransportChange( + timeoutMs = 4000L, + onFailureAction = { customActionCalled = true }, + ) + runCurrent() + + assertEquals(listOf(4000L), fixture.pingCalls) + assertTrue(customActionCalled) + assertEquals(0, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnTransportChangeFailureInvokesDefaultCancelSocket() = + runTest { + val fixture = + Fixture( + scope = this, + pingShouldFail = true, + ) + try { + fixture.health.probeLivenessOnTransportChange( + timeoutMs = 5000L, + ) + runCurrent() + + assertEquals(listOf(5000L), fixture.pingCalls) + assertEquals(1, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnWakeIdleCancelsImmediatelyOnFailure() = + runTest { + val fixture = + Fixture( + scope = this, + pingShouldFail = true, + pendingReplyState = false, + ) + try { + fixture.health.probeLivenessOnWake(deferredOnce = false) + runCurrent() + + assertEquals(listOf(5000L), fixture.pingCalls) + assertEquals(1, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnWakePendingReplyFirstFailureDefers3000MsAndRetriesOnce() = + runTest { + val fixture = + Fixture( + scope = this, + pingShouldFail = true, + pendingReplyState = true, + ) + try { + fixture.health.probeLivenessOnWake(deferredOnce = false) + runCurrent() + + // First probe fails, but because pendingReply=true, it does not cancel immediately. + // It schedules a deferred retry after 3000ms. + assertEquals(1, fixture.pingCalls.size) + assertEquals(0, fixture.cancelCalls.get()) + + // Advance before 3000ms + advanceTimeBy(2999L) + runCurrent() + assertEquals(1, fixture.pingCalls.size) + assertEquals(0, fixture.cancelCalls.get()) + + // Advance to 3000ms - second ping is executed + advanceTimeBy(1L) + runCurrent() + assertEquals(2, fixture.pingCalls.size) + // Second probe fails, now it cancels + assertEquals(1, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnWakePendingReplySecondProbeSucceedsLeavesSocketOpen() = + runTest { + val fixture = + Fixture( + scope = this, + pingShouldFail = true, + pendingReplyState = true, + pingDelayMs = 10L, + ) + try { + fixture.health.probeLivenessOnWake(deferredOnce = false) + runCurrent() + + // First attempt failed, pending deferred retry in 3000ms + assertEquals(1, fixture.pingCalls.size) + assertEquals(0, fixture.cancelCalls.get()) + + // Before retry fires, recovery occurs: pings now succeed + fixture.pingShouldFail = false + + advanceTimeBy(3000L) + runCurrent() + + assertEquals(2, fixture.pingCalls.size) + assertEquals(0, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun cancelForegroundProbeCancelsPendingProbe() = + runTest { + val fixture = + Fixture( + scope = this, + pingShouldFail = true, + pendingReplyState = true, + ) + try { + fixture.health.probeLivenessOnWake(deferredOnce = false) + runCurrent() + + assertEquals(1, fixture.pingCalls.size) + assertEquals(0, fixture.cancelCalls.get()) + + // Cancel the scheduled retry + fixture.health.cancelForegroundProbe() + + advanceTimeBy(5000L) + runCurrent() + + // No second probe should have been made, no cancellation + assertEquals(1, fixture.pingCalls.size) + assertEquals(0, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnTransportChangeSuccessLogsAtDebugLevelWithoutWarning() = + runTest { + val fixture = + Fixture( + scope = this, + pingDelayMs = 25L, + ) + try { + fixture.health.probeLivenessOnTransportChange(timeoutMs = 5000L) + runCurrent() + advanceTimeBy(25L) + runCurrent() + + assertEquals(listOf(5000L), fixture.pingCalls) + assertEquals( + listOf( + "Network transport changed — probing WebSocket liveness", + "WebSocket liveness probe succeeded on new transport", + ), + fixture.debugLogs, + ) + assertTrue(fixture.logs.isEmpty()) + assertEquals(0, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnTransportChangeFailureLogsDebugAndWarningAndCancelsSocket() = + runTest { + val fixture = + Fixture( + scope = this, + pingShouldFail = true, + ) + try { + fixture.health.probeLivenessOnTransportChange(timeoutMs = 5000L) + runCurrent() + + assertEquals(listOf(5000L), fixture.pingCalls) + assertEquals( + listOf("Network transport changed — probing WebSocket liveness"), + fixture.debugLogs, + ) + assertEquals( + listOf("Transport change liveness probe failed — cancelling socket"), + fixture.logs, + ) + assertEquals(1, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientCharacterizationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientCharacterizationTest.kt new file mode 100644 index 00000000..77ec705d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientCharacterizationTest.kt @@ -0,0 +1,532 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +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.Assert.fail +import org.junit.Before +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Characterization test suite capturing existing baseline behaviors and edge cases + * of [HermesWsClient] before extracting components in issue #1376. + */ +class HermesWsClientCharacterizationTest { + private lateinit var mockWebServer: MockWebServer + private val serverSockets = Collections.synchronizedList(mutableListOf()) + private lateinit var serverClosed: CountDownLatch + + @Before + fun setUp() { + serverSockets.clear() + serverClosed = CountDownLatch(1) + mockkStatic(Log::class) + every { Log.isLoggable(any(), any()) } returns false + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.connectedForTest.set(false) + HermesWsClient.connectionStatusForTest.value = ConnectionStatus.DISCONNECTED + HermesWsClient.messageQueueForTest.clear() + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + HermesWsClient.acceptQueuedMessagesForTest.set(true) + HermesWsClient.clearCredentialWarning() + HermesWsClient.clearSeqWatermarks() + HermesWsClient.setLastLatencyForTest(null) + } + + @After + fun tearDown() { + HermesWsClient.rejectAllPending() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeout(3000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } + } + } + try { + if (serverSockets.isNotEmpty()) { + assertTrue("Server endpoint must finish closing", serverClosed.await(3, TimeUnit.SECONDS)) + } + mockWebServer.shutdown() + } finally { + unmockkAll() + } + } + + private fun connectClientToServer(onServerOpen: ((WebSocket) -> Unit)? = null): WebSocket { + val serverOpenLatch = CountDownLatch(1) + var serverWs: WebSocket? = null + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + webSocket.close(code, reason) + } + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + serverClosed.countDown() + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: okhttp3.Response?, + ) { + serverClosed.countDown() + } + + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverSockets.add(webSocket) + serverWs = webSocket + onServerOpen?.invoke(webSocket) + serverOpenLatch.countDown() + } + }, + ), + ) + + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } + } + } + assertTrue("Server WebSocket should open", serverOpenLatch.await(5, TimeUnit.SECONDS)) + return requireNotNull(serverWs) + } + + @Test + fun credentialWarning_updatedByGatewayReadyAndSessionInfo_retainedOnBlank_clearedExplicitly() { + assertNull(HermesWsClient.credentialWarning.value) + + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectedForTest.set(true) + val dummySocket = + mockk(relaxed = true) { + every { send(any()) } returns true + } + HermesWsClient.webSocketForTest = dummySocket + val generation = HermesWsClient.connectionGenerationForTest.get() + val listener = HermesWsClient.createListenerForTest(generation) + + // 1. GatewayReady with non-blank credential_warning updates flow + val gatewayReadyFrame = + """{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"credential_warning":"Warning: Auth key expired"}}}""" + listener.onMessage(dummySocket, gatewayReadyFrame) + + runBlocking { + withTimeout(3000) { + HermesWsClient.credentialWarning.first { it == "Warning: Auth key expired" } + } + } + assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value) + + // 2. Blank or absent credential_warning does NOT clear the existing value. + // Deliver follow-up gateway.ready (absent) after blank and check warning is retained, + // then use session.info barrier to prove processing pipeline is drained. + val blankFrame = + """{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"credential_warning":""}}}""" + listener.onMessage(dummySocket, blankFrame) + assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value) + + val absentFrame = + """{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"version":"1.0"}}}""" + listener.onMessage(dummySocket, absentFrame) + assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value) + + // Barrier: a distinct non-blank session.info proves all preceding frames were processed in order + val barrierFrame = + """{"jsonrpc":"2.0","method":"event","params":{"type":"session.info","session_id":"s-barrier","payload":{"credential_warning":"Barrier reached"}}}""" + listener.onMessage(dummySocket, barrierFrame) + runBlocking { + withTimeout(3000) { + HermesWsClient.credentialWarning.first { it == "Barrier reached" } + } + } + assertEquals("Barrier reached", HermesWsClient.credentialWarning.value) + + // 3. SessionInfo with non-blank updates warning + val sessionInfoFrame = + """{"jsonrpc":"2.0","method":"event","params":{"type":"session.info","session_id":"s1","payload":{"credential_warning":"Session warning"}}}""" + listener.onMessage(dummySocket, sessionInfoFrame) + + runBlocking { + withTimeout(3000) { + HermesWsClient.credentialWarning.first { it == "Session warning" } + } + } + assertEquals("Session warning", HermesWsClient.credentialWarning.value) + + // 4. Explicit clear resets to null + HermesWsClient.clearCredentialWarning() + assertNull(HermesWsClient.credentialWarning.value) + } + + @Test + fun disconnect_resetsLastLatencyMsToNull() { + HermesWsClient.setLastLatencyForTest(42L) + assertEquals(42L, HermesWsClient.lastLatencyMs.value) + + HermesWsClient.disconnect() + assertNull(HermesWsClient.lastLatencyMs.value) + } + + @Test + fun rpcTimeout_emitsHermesRpcExceptionCodeMinusOneExactMessage() { + val serverWs = connectClientToServer() + assertNotNull(serverWs) + + val shortTimeoutMs = 150L + val deferred = HermesWsClient.request("custom.test_method", emptyMap(), timeoutMs = shortTimeoutMs) + + try { + runBlocking { + withTimeout(3000) { + deferred.await() + } + } + fail("Expected deferred to fail with timeout exception") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals(-1, e.code) + assertEquals("Request timed out: custom.test_method", e.message) + } + } + + @Test + fun rejectAllPending_failsRequestsWithCustomException_cancelsTimeout_clearsPending() { + connectClientToServer() + + val deferred = HermesWsClient.request("custom.hold", emptyMap(), timeoutMs = 10_000L) + val pendingIds = HermesWsClient.pendingCallIdsForTest() + assertEquals(1, pendingIds.size) + val reqId = pendingIds.first() + + val timeoutJob = HermesWsClient.pendingCallTimeoutForTest(reqId) + assertNotNull("Timeout job should be scheduled", timeoutJob) + assertTrue("Timeout job should be active", timeoutJob?.isActive == true) + + val customException = HermesWsClient.HermesRpcException("Custom forced rejection", code = -99) + HermesWsClient.rejectAllPending(customException) + + assertTrue( + "Pending map should be empty after rejectAllPending", + HermesWsClient.pendingCallIdsForTest().isEmpty(), + ) + assertTrue("Timeout job should be cancelled", timeoutJob?.isCancelled == true) + assertTrue("Deferred should be completed exceptionally", deferred.isCompleted) + + try { + runBlocking { deferred.await() } + fail("Expected custom exception") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals(-99, e.code) + assertEquals("Custom forced rejection", e.message) + } + } + + @Test + fun reconnectForNetworkChange_rejectsPendingCallsImmediately() { + connectClientToServer() + + val deferred = HermesWsClient.request("custom.in_flight", emptyMap(), timeoutMs = 30_000L) + assertEquals(1, HermesWsClient.pendingCallIdsForTest().size) + + // Network change triggers immediate rejectAllPending + HermesWsClient.reconnectForNetworkChange(networkAvailable = false) + + assertTrue("Pending map should be empty after network change", HermesWsClient.pendingCallIdsForTest().isEmpty()) + assertTrue("Deferred must be completed", deferred.isCompleted) + + try { + runBlocking { deferred.await() } + fail("Expected pending call to be rejected on network change") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals("Connection lost — request cancelled", e.message) + } + } + + @Test + fun disconnectAndListenerClosed_doNotRejectAllPending_deferredRemainsPendingUntilTimeoutOrReject() { + val serverWs = connectClientToServer() + + val shortTimeoutMs = 300L + val deferred = HermesWsClient.request("custom.deferred_call", emptyMap(), timeoutMs = shortTimeoutMs) + val reqId = HermesWsClient.pendingCallIdsForTest().first() + + // Drive onClosed on the CURRENT active generation before disconnect. + // In onClosed: isCurrent() is true (intentionalClose is still false, generation matches). + // This exercises the active listener onClosed branch. + val activeGeneration = HermesWsClient.connectionGenerationForTest.get() + val currentListener = HermesWsClient.createListenerForTest(activeGeneration) + val activeClientSocket = HermesWsClient.webSocketForTest ?: serverWs + + currentListener.onClosed(activeClientSocket, 1000, "Normal closure") + activeClientSocket.cancel() + + // Deferred remains pending after active onClosed + assertFalse("Deferred should still remain pending after onClosed", deferred.isCompleted) + assertTrue("Request ID should still be in pendingCalls", HermesWsClient.pendingCallIdsForTest().contains(reqId)) + + // Calling disconnect() does NOT invoke rejectAllPending() either + HermesWsClient.disconnect() + + assertFalse("Deferred should NOT be completed immediately by disconnect() alone", deferred.isCompleted) + assertTrue("Request ID should still be in pendingCalls", HermesWsClient.pendingCallIdsForTest().contains(reqId)) + + // Eventually, the per-request timeout triggers and resolves it + try { + runBlocking { + withTimeout(2000) { + deferred.await() + } + } + fail("Deferred should fail with timeout") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals(-1, e.code) + assertEquals("Request timed out: custom.deferred_call", e.message) + } + } + + @Test + fun combinedExternalAndBackgroundLeases_releaseOneKeepsOther_releaseBothDisconnectsWhenBackgrounded() { + connectClientToServer() + assertTrue(HermesWsClient.isConnected) + + // Acquire both leases + HermesWsClient.acquireExternalActivityConnectionLease() + HermesWsClient.acquireBackgroundConnectionLease() + assertTrue(HermesWsClient.hasBackgroundConnectionLease()) + + // Move to background - should NOT disconnect because leases are active + HermesWsClient.setAppForeground(false) + assertTrue("Should stay connected while leases are active", HermesWsClient.isConnected) + + // Release external lease - background lease still holds connection open + HermesWsClient.releaseExternalActivityConnectionLease() + assertTrue("Should stay connected with background lease active", HermesWsClient.isConnected) + + // Release background lease - both leases released, app backgrounded, idle -> disconnects + HermesWsClient.releaseBackgroundConnectionLease() + assertFalse("Should disconnect when all leases released in background", HermesWsClient.isConnected) + } + + @Test + fun delayedReplayAndLiveRace_incomingSeqBufferedBeforeReplayResponseAndDeduped() { + val sessionId = "session-characterization-race" + val serverOpenLatch = CountDownLatch(1) + val replayReceivedLatch = CountDownLatch(1) + val allowReplayResponseLatch = CountDownLatch(1) + val capturedReplayId = AtomicReference() + var serverSocketRef: WebSocket? = null + + // Enqueue MockResponse with WebSocketListener on MockWebServer + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + webSocket.close(code, reason) + } + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + serverClosed.countDown() + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: okhttp3.Response?, + ) { + serverClosed.countDown() + } + + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverSockets.add(webSocket) + serverSocketRef = webSocket + serverOpenLatch.countDown() + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + if (text.contains(WsMethods.SESSION_EVENTS_SINCE)) { + val request = OkHttpProvider.json.parseToJsonElement(text) as JsonObject + val id = (request["id"] as JsonPrimitive).content + capturedReplayId.set(id) + replayReceivedLatch.countDown() + + if (allowReplayResponseLatch.await(5, TimeUnit.SECONDS)) { + val rpcId = requireNotNull(capturedReplayId.get()) + val idJson = JsonPrimitive(rpcId).toString() + val replayResponse = + """{"jsonrpc":"2.0","id":$idJson,"result":{"epoch":"epoch-1","latest_seq":12,"events":[{"type":"message.complete","session_id":"$sessionId","seq":11,"payload":{"text":"Replayed message 11"}},{"type":"message.complete","session_id":"$sessionId","seq":12,"payload":{"text":"Replayed message 12"}}]}}""" + webSocket.send(replayResponse) + } + } + } + }, + ), + ) + + // Set watermark at seq 10 and epoch-1 prior to connect + HermesWsClient.setSeqWatermark(sessionId, 10) + HermesWsClient.setReplayEpochForTest("epoch-1") + + // Prepare subscription with onSubscription latch before stimulus + val collectedEvents = Collections.synchronizedList(mutableListOf()) + val subscriptionLatch = CountDownLatch(1) + val collectLatch = CountDownLatch(2) + val liveProcessedLatch = CountDownLatch(1) + + val collectorJob = + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + HermesWsClient.events + .onSubscription { + subscriptionLatch.countDown() + }.collect { event -> + if (event is WsEvent.SessionInfo && event.data?.get("barrier") == "live-processed") { + liveProcessedLatch.countDown() + } + if (event is WsEvent.MessageComplete && event.sessionId == sessionId) { + collectedEvents.add(event) + collectLatch.countDown() + } + } + } + + try { + assertTrue("Collector subscription should be established", subscriptionLatch.await(3, TimeUnit.SECONDS)) + + // Connect client - real onOpen automatically arms replay + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } + } + } + assertTrue("Server WebSocket should open", serverOpenLatch.await(5, TimeUnit.SECONDS)) + val serverSocket = requireNotNull(serverSocketRef) + + // Wait until the server receives session.events.since request + assertTrue("Server should receive replay request", replayReceivedLatch.await(5, TimeUnit.SECONDS)) + assertTrue("Replay should be in flight", HermesWsClient.isReplayInFlight()) + + // 1. Live frame arrived while replay is in flight (seq 12) + val liveFrame = + """{"jsonrpc":"2.0","method":"event","params":{"type":"message.complete","session_id":"$sessionId","seq":12,"payload":{"text":"Live message 12"}}}""" + serverSocket.send(liveFrame) + + // A following unsequenced frame proves the live notification reached the client + // before the replay response is released; unchanged watermark alone is not a barrier. + serverSocket.send( + """{"method":"event","params":{"type":"session.info","session_id":"$sessionId","payload":{"barrier":"live-processed"}}}""", + ) + assertTrue( + "Live frame must be processed before replay completes", + liveProcessedLatch.await(3, TimeUnit.SECONDS), + ) + assertEquals(10, HermesWsClient.getSeqWatermarks()[sessionId]) + assertEquals("Live frame should be held in replayHold while replay is in flight", 0, collectedEvents.size) + + // 2. Unblock replay response from server + allowReplayResponseLatch.countDown() + + // Wait for replay to deliver replayed events and drain held live events + assertTrue("Should collect replayed and unheld events", collectLatch.await(5, TimeUnit.SECONDS)) + + // Watermark should advance to 12 + assertEquals(12, HermesWsClient.getSeqWatermarks()[sessionId]) + + // Deduplication: held live frame seq 12 deduped against replay seq 12 + assertEquals(2, collectedEvents.size) + assertEquals("Replayed message 11", (collectedEvents[0] as WsEvent.MessageComplete).text) + assertEquals("Replayed message 12", (collectedEvents[1] as WsEvent.MessageComplete).text) + } finally { + allowReplayResponseLatch.countDown() + collectorJob.cancel() + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt index 29bfaa84..e4fcd588 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt @@ -11,7 +11,6 @@ import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.launch @@ -30,11 +29,8 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.util.Collections -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger class HermesWsClientLoggingSecurityTest { private val capturedDebugLogs = Collections.synchronizedList(mutableListOf()) @@ -87,17 +83,9 @@ class HermesWsClientLoggingSecurityTest { CookieManager.setJarForTest(buildFakePersistentCookieJar()) - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as AtomicBoolean).set(false) - - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false) - - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as AtomicInteger).set(1) + HermesWsClient.connectedForTest.set(false) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionGenerationForTest.set(1) HermesWsClient.rejectAllPending() HermesWsClient.disconnect(clearPendingMessages = true) @@ -113,51 +101,20 @@ class HermesWsClientLoggingSecurityTest { unmockkAll() } - private fun createWsListener(generation: Int? = null): WebSocketListener { - val targetGen = - generation ?: run { - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as AtomicInteger).get() - } - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - return constructor.newInstance(targetGen) as WebSocketListener - } + private fun createWsListener(generation: Int? = null): WebSocketListener = + HermesWsClient.createListenerForTest(generation ?: HermesWsClient.connectionGenerationForTest.get()) private fun attachMockSocket(): WebSocket { val socket = mockk(relaxed = true) every { socket.send(any()) } returns true - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, socket) - - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as AtomicBoolean).set(true) - - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false) + HermesWsClient.webSocketForTest = socket + HermesWsClient.connectedForTest.set(true) + HermesWsClient.intentionalCloseForTest.set(false) return socket } - @Suppress("UNCHECKED_CAST") - private fun getPendingCalls(): ConcurrentHashMap { - val field = HermesWsClient::class.java.getDeclaredField("pendingCalls") - field.isAccessible = true - return field.get(HermesWsClient) as ConcurrentHashMap - } - - private fun getTimeoutJob(pendingCall: Any): Job? { - val field = pendingCall.javaClass.getDeclaredField("timeoutJob") - field.isAccessible = true - return field.get(pendingCall) as? Job - } - // ── Formatter unit tests ────────────────────────────────────────────────── @Test @@ -255,8 +212,7 @@ class HermesWsClientLoggingSecurityTest { val listener = createWsListener() val deferred = HermesWsClient.request("connectors.connect", mapOf("connector" to "synthetic_provider")) - val pendingCalls = getPendingCalls() - val reqId = pendingCalls.keys.firstOrNull() + val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull() assertNotNull("Pending call ID must exist", reqId) val sensitiveAuthUrl = @@ -332,8 +288,7 @@ class HermesWsClientLoggingSecurityTest { assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS)) val deferred = HermesWsClient.request("session.steer", mapOf("session_id" to "sess-ownership-4001")) - val pendingCalls = getPendingCalls() - val reqId = pendingCalls.keys.firstOrNull() + val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull() assertNotNull("Pending call ID must exist", reqId) val errorJson = @@ -434,15 +389,9 @@ class HermesWsClientLoggingSecurityTest { fun `cancelling pending deferred cleans pending requests and cancels timeout job`() { attachMockSocket() val deferred = HermesWsClient.request("test.cancellation.method", mapOf("synthetic" to "fixture")) - val pendingCalls = getPendingCalls() - val entry = - pendingCalls.entries.firstOrNull { - it.value.javaClass.name - .contains("PendingCall") - } - assertNotNull("Pending call must be registered in pendingCalls map", entry) - val reqId = entry!!.key - val timeoutJob = getTimeoutJob(entry.value) + val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull() + assertNotNull("Pending call must be registered in pendingCalls map", reqId) + val timeoutJob = HermesWsClient.pendingCallTimeoutForTest(reqId!!) assertNotNull("Timeout job must be initialized", timeoutJob) assertTrue("Timeout job must be active while request is pending", timeoutJob!!.isActive) @@ -451,7 +400,7 @@ class HermesWsClientLoggingSecurityTest { assertTrue("Deferred must be cancelled", deferred.isCancelled) assertFalse( "Pending call must be removed from pendingCalls map on cancellation", - pendingCalls.containsKey(reqId), + HermesWsClient.pendingCallIdsForTest().contains(reqId), ) assertTrue("Timeout job must be cancelled on deferred cancellation", timeoutJob.isCancelled) } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt index 14d5800e..7b5c1331 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt @@ -91,27 +91,15 @@ class HermesWsClientTest { every { DashboardSessionTokenRefresher.refresh() } returns null // Reset state - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - - val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus") - statusField.isAccessible = true - @Suppress("UNCHECKED_CAST") - (statusField.get(HermesWsClient) as MutableStateFlow).value = ConnectionStatus.DISCONNECTED - - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - (queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue).clear() + HermesWsClient.connectedForTest.set(false) + HermesWsClient.connectionStatusForTest.value = ConnectionStatus.DISCONNECTED + HermesWsClient.messageQueueForTest.clear() HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean HermesWsClient.releaseExternalActivityConnectionLease() HermesWsClient.releaseBackgroundConnectionLease() HermesWsClient.setAppForeground(true) - val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages") - acceptQueuedMessagesField.isAccessible = true - (acceptQueuedMessagesField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.acceptQueuedMessagesForTest.set(true) } @After @@ -350,10 +338,8 @@ class HermesWsClientTest { val socket = mockk() every { socket.send(any()) } returns true every { socket.close(any(), any()) } returns true - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true } - val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true } - socketField.set(HermesWsClient, socket) - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.webSocketForTest = socket + HermesWsClient.connectedForTest.set(true) val sent = HermesWsClient.respondToServerRequest( @@ -384,10 +370,8 @@ class HermesWsClientTest { val socket = mockk() every { socket.send(any()) } returns true every { socket.close(any(), any()) } returns true - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true } - val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true } - socketField.set(HermesWsClient, socket) - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.webSocketForTest = socket + HermesWsClient.connectedForTest.set(true) assertTrue(HermesWsClient.respondToServerRequestError("srq-abc123", -32601, "unsupported")) verify { @@ -416,29 +400,14 @@ class HermesWsClientTest { val staleSocket = mockk(relaxed = true) every { staleSocket.send(any()) } returns false - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, staleSocket) - - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) - - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - - val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus") - statusField.isAccessible = true - @Suppress("UNCHECKED_CAST") - (statusField.get(HermesWsClient) as MutableStateFlow).value = ConnectionStatus.CONNECTED + HermesWsClient.webSocketForTest = staleSocket + HermesWsClient.connectedForTest.set(true) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionStatusForTest.value = ConnectionStatus.CONNECTED HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello")) - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest assertFalse(HermesWsClient.isConnected) assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value) assertEquals(1, queue.size) @@ -446,8 +415,7 @@ class HermesWsClientTest { @Test fun testSendRegistersRequestIdWhileOutboundLockHeld() { - val lockField = HermesWsClient::class.java.getDeclaredField("outboundLock").apply { isAccessible = true } - val outboundLock = lockField.get(HermesWsClient) + val outboundLock = HermesWsClient.outboundLockForTest var callbackHeldLock = false HermesWsClient.send("test.method", onSent = { callbackHeldLock = Thread.holdsLock(outboundLock) }) @@ -458,25 +426,13 @@ class HermesWsClientTest { @Test fun testNetworkChangeInvalidatesOldSocketAndOpensReplacementImmediately() { every { AuthManager.isAutoReconnect() } returns true - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectedForTest.set(true) val socket = mockk(relaxed = true) every { socket.send(any()) } returns true - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, socket) - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger - val oldGeneration = generation.get() - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val staleListener = constructor.newInstance(oldGeneration) as WebSocketListener + HermesWsClient.webSocketForTest = socket + val oldGeneration = HermesWsClient.connectionGenerationForTest.get() + val staleListener = HermesWsClient.createListenerForTest(oldGeneration) var replacementOpens = 0 val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1")) @@ -486,10 +442,10 @@ class HermesWsClientTest { verify(exactly = 1) { socket.cancel() } assertEquals(1, replacementOpens) - assertEquals(oldGeneration + 1, generation.get()) + assertEquals(oldGeneration + 1, HermesWsClient.connectionGenerationForTest.get()) assertFalse(HermesWsClient.isConnected) assertEquals(ConnectionStatus.RECONNECTING, HermesWsClient.connectionStatus.value) - assertEquals(null, socketField.get(HermesWsClient)) + assertEquals(null, HermesWsClient.webSocketForTest) assertTrue(deferred.isCompleted) } @@ -497,18 +453,10 @@ class HermesWsClientTest { fun testNetworkLossPreservesAuthExpiredStatus() = runBlocking { every { AuthManager.isAutoReconnect() } returns true - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus") - statusField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val status = statusField.get(HermesWsClient) as MutableStateFlow - status.value = ConnectionStatus.AUTH_EXPIRED + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED val socket = mockk(relaxed = true) - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, socket) + HermesWsClient.webSocketForTest = socket HermesWsClient.reconnectForNetworkChange(false) HermesWsClient.reconnectForNetworkChange(true) @@ -520,22 +468,13 @@ class HermesWsClientTest { @Test fun testBreakBeforeMakeDoesNotReplayAcceptedRequest() { every { AuthManager.isAutoReconnect() } returns true - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectedForTest.set(true) val oldSocket = mockk(relaxed = true) every { oldSocket.send(any()) } returns true - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, oldSocket) + HermesWsClient.webSocketForTest = oldSocket HermesWsClient.send(WsMethods.SUBSCRIPTION_CHANGE, mapOf("cancel" to true)) - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest assertTrue(queue.isEmpty()) HermesWsClient.reconnectForNetworkChange(false) @@ -545,13 +484,7 @@ class HermesWsClientTest { HermesWsClient.reconnectForNetworkChange(true) { replacementOpens++ } val replacementSocket = mockk(relaxed = true) every { replacementSocket.send(any()) } returns true - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val replacementListener = constructor.newInstance(generation.get()) as WebSocketListener + val replacementListener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get()) replacementListener.onOpen(replacementSocket, mockk(relaxed = true)) assertEquals(1, replacementOpens) @@ -563,18 +496,10 @@ class HermesWsClientTest { @Test fun testNetworkChangePreservesAuthExpiredSocket() { every { AuthManager.isAutoReconnect() } returns true - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus") - statusField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val status = statusField.get(HermesWsClient) as MutableStateFlow - status.value = ConnectionStatus.AUTH_EXPIRED + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED val socket = mockk(relaxed = true) - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, socket) + HermesWsClient.webSocketForTest = socket HermesWsClient.reconnectForNetworkChange() @@ -588,20 +513,13 @@ class HermesWsClientTest { every { AuthManager.isAutoReconnect() } returns true every { NetworkMonitor.isConnected } returns MutableStateFlow(true) - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.currentBackoffForTest = 1_000L - val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff") - backoffField.isAccessible = true - backoffField.setLong(HermesWsClient, 1_000L) + HermesWsClient.scheduleReconnectForTest() + HermesWsClient.scheduleReconnectForTest() - val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect") - reconnectMethod.isAccessible = true - reconnectMethod.invoke(HermesWsClient) - reconnectMethod.invoke(HermesWsClient) - - assertEquals(2_000L, backoffField.getLong(HermesWsClient)) + assertEquals(2_000L, HermesWsClient.currentBackoffForTest) } @Test @@ -610,35 +528,21 @@ class HermesWsClientTest { every { AuthManager.isAutoReconnect() } returns true every { NetworkMonitor.isConnected } returns MutableStateFlow(true) - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectedForTest.set(false) + HermesWsClient.currentBackoffForTest = 0L - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - - val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff") - backoffField.isAccessible = true - backoffField.setLong(HermesWsClient, 0L) - - val reconnectJobField = HermesWsClient::class.java.getDeclaredField("reconnectJob") - reconnectJobField.isAccessible = true val replacementJob = mockk(relaxed = true) - val lockField = HermesWsClient::class.java.getDeclaredField("outboundLock") - lockField.isAccessible = true - val lock = lockField.get(HermesWsClient) - val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect") - reconnectMethod.isAccessible = true + val lock = HermesWsClient.outboundLockForTest synchronized(lock) { - reconnectMethod.invoke(HermesWsClient) - reconnectJobField.set(HermesWsClient, replacementJob) + HermesWsClient.scheduleReconnectForTest() + HermesWsClient.reconnectJobForTest = replacementJob } Thread.sleep(100) - assertSame(replacementJob, reconnectJobField.get(HermesWsClient)) + assertSame(replacementJob, HermesWsClient.reconnectJobForTest) } @Test @@ -646,23 +550,15 @@ class HermesWsClientTest { val staleSocket = mockk(relaxed = true) every { staleSocket.send(any()) } returns false - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, staleSocket) - - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.webSocketForTest = staleSocket + HermesWsClient.connectedForTest.set(true) HermesWsClient.send( WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)), ) - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest assertTrue(queue.isEmpty()) io.mockk.verify(exactly = 0) { staleSocket.cancel() } } @@ -674,10 +570,7 @@ class HermesWsClientTest { mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)), ) - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest assertTrue(queue.isEmpty()) } @@ -687,24 +580,14 @@ class HermesWsClientTest { every { pressuredSocket.send(any()) } returns false every { pressuredSocket.queueSize() } returns 1024L - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, pressuredSocket) - - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) - - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.webSocketForTest = pressuredSocket + HermesWsClient.connectedForTest.set(true) + HermesWsClient.intentionalCloseForTest.set(false) HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello")) io.mockk.verify(exactly = 0) { pressuredSocket.cancel() } - val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob") - drainJobField.isAccessible = true - assertNotNull(drainJobField.get(HermesWsClient)) + assertNotNull(HermesWsClient.outboundDrainJobForTest) } @Test @@ -717,35 +600,18 @@ class HermesWsClientTest { every { pressuredSocket.send(any()) } returns false every { pressuredSocket.queueSize() } returns 1024L - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, pressuredSocket) - - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) - - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1) - - val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff") - backoffField.isAccessible = true - backoffField.setLong(HermesWsClient, 1_000L) + HermesWsClient.webSocketForTest = pressuredSocket + HermesWsClient.connectedForTest.set(true) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionGenerationForTest.set(1) + HermesWsClient.currentBackoffForTest = 1_000L HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello")) - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val listener = constructor.newInstance(1) as WebSocketListener + val listener = HermesWsClient.createListenerForTest(1) listener.onFailure(pressuredSocket, IOException("connection lost"), null) - assertEquals(2_000L, backoffField.getLong(HermesWsClient)) + assertEquals(2_000L, HermesWsClient.currentBackoffForTest) } @Test @@ -754,38 +620,22 @@ class HermesWsClientTest { every { pressuredSocket.send(any()) } returnsMany listOf(true, false) every { pressuredSocket.queueSize() } returns 1024L - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}") queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"2\"}") - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionGenerationForTest.set(1) + HermesWsClient.webSocketForTest = pressuredSocket - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1) - - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, pressuredSocket) - - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val listener = constructor.newInstance(1) as WebSocketListener + val listener = HermesWsClient.createListenerForTest(1) listener.onOpen(pressuredSocket, mockk(relaxed = true)) assertFalse(HermesWsClient.isConnected) assertEquals(1, queue.size) assertTrue(queue.peek()?.contains("\"2\"") == true) io.mockk.verify(exactly = 0) { pressuredSocket.cancel() } - val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob") - drainJobField.isAccessible = true - assertNotNull(drainJobField.get(HermesWsClient)) + assertNotNull(HermesWsClient.outboundDrainJobForTest) } @Test @@ -794,29 +644,15 @@ class HermesWsClientTest { val currentSocket = mockk(relaxed = true) every { currentSocket.send(any()) } returns true - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, currentSocket) + HermesWsClient.webSocketForTest = currentSocket + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionGenerationForTest.set(2) - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(2) - - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}") - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val staleListener = constructor.newInstance(1) as WebSocketListener - val currentListener = constructor.newInstance(2) as WebSocketListener + val staleListener = HermesWsClient.createListenerForTest(1) + val currentListener = HermesWsClient.createListenerForTest(2) val response = mockk(relaxed = true) staleListener.onOpen(staleSocket, response) @@ -835,40 +671,24 @@ class HermesWsClientTest { false } - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, staleSocket) - - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) - - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.webSocketForTest = staleSocket + HermesWsClient.connectedForTest.set(true) + HermesWsClient.intentionalCloseForTest.set(false) HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello")) - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest assertTrue(queue.isEmpty()) assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value) } @Test fun testDisconnectPreservesQueuedMessagesUnlessExplicitlyCleared() { - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.intentionalCloseForTest.set(false) HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello")) - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest assertEquals(1, queue.size) HermesWsClient.disconnect() @@ -886,16 +706,11 @@ class HermesWsClientTest { @Test fun testRejectAllPendingRemovesQueuedAwaitedRpc() { - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.intentionalCloseForTest.set(false) val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1")) - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest assertEquals(1, queue.size) HermesWsClient.rejectAllPending() @@ -910,26 +725,12 @@ class HermesWsClientTest { every { socket.send(any()) } returns false every { socket.queueSize() } returns 0L - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, socket) + HermesWsClient.webSocketForTest = socket + HermesWsClient.connectedForTest.set(true) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionGenerationForTest.set(1) - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) - - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1) - - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val listener = constructor.newInstance(1) as WebSocketListener + val listener = HermesWsClient.createListenerForTest(1) listener.onClosing(socket, 4401, "expired") HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello")) @@ -941,21 +742,10 @@ class HermesWsClientTest { @Test fun testSocketFailureDoesNotOverwriteAuthExpired() { val socket = mockk(relaxed = true) - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1) - val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus") - statusField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val status = statusField.get(HermesWsClient) as MutableStateFlow - status.value = ConnectionStatus.AUTH_EXPIRED - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val listener = constructor.newInstance(1) as WebSocketListener + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionGenerationForTest.set(1) + HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED + val listener = HermesWsClient.createListenerForTest(1) listener.onFailure(socket, IOException("network changed"), null) @@ -965,18 +755,10 @@ class HermesWsClientTest { @Test fun testExplicitConnectRetriesAfterAuthExpired() = runBlocking { - val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus") - statusField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val status = statusField.get(HermesWsClient) as MutableStateFlow - status.value = ConnectionStatus.AUTH_EXPIRED - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED + HermesWsClient.connectedForTest.set(true) val oldSocket = mockk(relaxed = true) - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, oldSocket) + HermesWsClient.webSocketForTest = oldSocket mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) HermesWsClient.connect() @@ -1008,17 +790,16 @@ class HermesWsClientTest { HermesWsClient.disconnect(clearPendingMessages = true) mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger - val disconnectedGeneration = generation.get() + val disconnectedGeneration = HermesWsClient.connectionGenerationForTest.get() val replacementConnect = thread { HermesWsClient.connect() } val generationDeadline = System.currentTimeMillis() + 5_000 - while (generation.get() == disconnectedGeneration && System.currentTimeMillis() < generationDeadline) { + while (HermesWsClient.connectionGenerationForTest.get() == disconnectedGeneration && + System.currentTimeMillis() < generationDeadline + ) { Thread.sleep(10) } - assertTrue(generation.get() > disconnectedGeneration) - val currentGeneration = generation.get() + assertTrue(HermesWsClient.connectionGenerationForTest.get() > disconnectedGeneration) + val currentGeneration = HermesWsClient.connectionGenerationForTest.get() releaseRefresh.countDown() staleConnect.join(5_000) @@ -1029,7 +810,7 @@ class HermesWsClientTest { assertFalse(staleConnect.isAlive) assertFalse(replacementConnect.isAlive) - assertEquals(currentGeneration, generation.get()) + assertEquals(currentGeneration, HermesWsClient.connectionGenerationForTest.get()) assertEquals(ConnectionStatus.CONNECTED, HermesWsClient.connectionStatus.value) verify(exactly = 0) { AuthManager.setToken("stale-token") } } @@ -1441,12 +1222,10 @@ class HermesWsClientTest { runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } } // After connect, backoff should be back to initial - val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff") - backoffField.isAccessible = true assertEquals( "Backoff should reset to initial after successful connect", 1000L, - backoffField.getLong(HermesWsClient), + HermesWsClient.currentBackoffForTest, ) } @@ -1999,26 +1778,11 @@ class HermesWsClientTest { fun testStreamingDedupPreservesSessionFallbackAndNumericSequenceSemantics() { // Issue #1163: exercise the real listener, including the early duplicate return. val socket = mockk(relaxed = true) - val intentionalClose = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalClose.isAccessible = true - (intentionalClose.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - val generation = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generation.isAccessible = true - val constructor = - Class - .forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - .declaredConstructors - .single() - constructor.isAccessible = true - val listener = - constructor.newInstance( - (generation.get(HermesWsClient) as AtomicInteger).get(), - ) as WebSocketListener + HermesWsClient.intentionalCloseForTest.set(false) + val listener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get()) // Directly install the reflection-built listener so onMessage runs even // though this test never performed a real OkHttp connect. - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, socket) + HermesWsClient.webSocketForTest = socket mockkObject(EventParser) val seqValues = listOf("6", "6.9", "4294967302", "\"6\"", "null", "true", "{}", "[]") @@ -2039,18 +1803,10 @@ class HermesWsClientTest { @Test fun testTerminalCloseCode4403SetsAuthExpired() { val socket = mockk(relaxed = true) - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionGenerationForTest.set(1) - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1) - - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val listener = constructor.newInstance(1) as WebSocketListener + val listener = HermesWsClient.createListenerForTest(1) listener.onClosing(socket, 4403, "forbidden origin") assertEquals(ConnectionStatus.AUTH_EXPIRED, HermesWsClient.connectionStatus.value) @@ -2182,9 +1938,7 @@ class HermesWsClientTest { @Test fun testProbeLivenessOnTransportChangeCancelsSocketWhenPingFails() { - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.connectedForTest.set(true) val failureLatch = CountDownLatch(1) HermesWsClient.probeLivenessOnTransportChange( @@ -2199,9 +1953,7 @@ class HermesWsClientTest { @Test fun testProbeLivenessOnTransportChangeSkipsWhenDisconnected() { - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.connectedForTest.set(false) var failureInvoked = false HermesWsClient.probeLivenessOnTransportChange( diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt index 9c7e23df..b8c7d01b 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt @@ -43,7 +43,6 @@ import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Before import org.junit.Test -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference @@ -122,13 +121,6 @@ class HermesWsClientTypedCallTest { } } - @Suppress("UNCHECKED_CAST") - private fun getPendingCalls(): ConcurrentHashMap { - val field = HermesWsClient::class.java.getDeclaredField("pendingCalls") - field.isAccessible = true - return field.get(HermesWsClient) as ConcurrentHashMap - } - @Test fun callPutsExactFrameOnTheWire() { val opened = CountDownLatch(1) @@ -516,19 +508,25 @@ class HermesWsClientTypedCallTest { assertTrue("Server should receive request", requestReceived.await(5, TimeUnit.SECONDS)) - val pending = getPendingCalls() - assertEquals("Should have exactly 1 pending call while awaiting", 1, pending.size) + assertEquals( + "Should have exactly 1 pending call while awaiting", + 1, + HermesWsClient.pendingCallIdsForTest().size, + ) callJob.cancel() // Wait briefly for cancellation cleanup to execute withTimeout(2000) { - while (pending.isNotEmpty()) { + while (HermesWsClient.pendingCallIdsForTest().isNotEmpty()) { kotlinx.coroutines.delay(20) } } - assertTrue("Pending calls should be empty after caller coroutine is cancelled", pending.isEmpty()) + assertTrue( + "Pending calls should be empty after caller coroutine is cancelled", + HermesWsClient.pendingCallIdsForTest().isEmpty(), + ) } } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ReconnectPolicyTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ReconnectPolicyTest.kt new file mode 100644 index 00000000..2b3936b3 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ReconnectPolicyTest.kt @@ -0,0 +1,98 @@ +package com.m57.hermescontrol.data.ws + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReconnectPolicyTest { + @Test + fun defaultInitialBackoffIs1000() { + val policy = ReconnectPolicy() + assertEquals(1000L, policy.currentBackoff) + } + + @Test + fun nextDelayMsUsesInjectedRandomAndFloorsAt300() { + // With random = 0.0, calculated delay is 0, floored to 300 (or ceiling if ceiling < 300) + val minPolicy = ReconnectPolicy(random = { 0.0 }) + assertEquals(300L, minPolicy.nextDelayMs()) + + // With random = 0.5 and ceiling = 1000, 0.5 * 1000 = 500 + val midPolicy = ReconnectPolicy(random = { 0.5 }) + assertEquals(500L, midPolicy.nextDelayMs()) + + // With random nearly 1.0 (0.999), 0.999 * 1000 = 999 + val maxPolicy = ReconnectPolicy(random = { 0.999 }) + assertEquals(999L, maxPolicy.nextDelayMs()) + } + + @Test + fun nextDelayMsRespectsCeilingCapsBelowFloorAndZero() { + // Ceiling <= 0 results in 0 + val zeroPolicy = ReconnectPolicy(random = { 0.5 }) + zeroPolicy.currentBackoff = 0L + assertEquals(0L, zeroPolicy.nextDelayMs()) + + val negativePolicy = ReconnectPolicy(random = { 0.5 }) + negativePolicy.currentBackoff = -100L + assertEquals(0L, negativePolicy.nextDelayMs()) + + // When ceiling is 100 (which is < 300 floor), delay should be capped to ceiling (100) + val lowCeilingPolicy = ReconnectPolicy(random = { 0.0 }) + lowCeilingPolicy.currentBackoff = 100L + assertEquals(100L, lowCeilingPolicy.nextDelayMs()) + } + + @Test + fun advanceDoublesBackoffUpToCapOf30000() { + val policy = ReconnectPolicy() + assertEquals(1000L, policy.currentBackoff) + + policy.advance() + assertEquals(2000L, policy.currentBackoff) + + policy.advance() + assertEquals(4000L, policy.currentBackoff) + + policy.advance() + assertEquals(8000L, policy.currentBackoff) + + policy.advance() + assertEquals(16000L, policy.currentBackoff) + + // Doubles past 30000 should cap exactly at 30000 (not speculative 15000 or 32000) + policy.advance() + assertEquals(30000L, policy.currentBackoff) + + policy.advance() + assertEquals(30000L, policy.currentBackoff) + } + + @Test + fun resetRestoresInitialBackoffOf1000() { + val policy = ReconnectPolicy() + policy.advance() + policy.advance() + assertEquals(4000L, policy.currentBackoff) + + policy.reset() + assertEquals(1000L, policy.currentBackoff) + } + + @Test + fun setBackoffForTestOverridesCurrentAndInitialBackoffDeterministically() { + val policy = ReconnectPolicy(random = { 0.5 }) + policy.setBackoffForTest(1500L) + + assertEquals(1500L, policy.currentBackoff) + // With test override, delay should be deterministic override value + assertEquals(1500L, policy.nextDelayMs()) + + // advance() still uses deterministic override if set, or advances from set initial/current + policy.advance() + assertEquals(1500L, policy.nextDelayMs()) + + // reset() retains deterministic override behavior or resets to overridden initial + policy.reset() + assertEquals(1500L, policy.nextDelayMs()) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ReplayTrackerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ReplayTrackerTest.kt new file mode 100644 index 00000000..7d1d3c4c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ReplayTrackerTest.kt @@ -0,0 +1,555 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class ReplayTrackerTest { + private val json = Json { ignoreUnknownKeys = true } + + private fun jsonElement(raw: String): JsonObject = json.parseToJsonElement(raw) as JsonObject + + @Test + fun `initial state has empty watermarks and no replay in flight`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = {}, + ) + + assertTrue(tracker.getSeqWatermarks().isEmpty()) + assertFalse(tracker.isReplayInFlight()) + } + + @Test + fun `watermarks can be set read and cleared without cancelling jobs`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = {}, + ) + + tracker.setSeqWatermark("s1", 10) + tracker.setSeqWatermark("s2", 25) + + assertEquals(mapOf("s1" to 10, "s2" to 25), tracker.getSeqWatermarks()) + + tracker.clearSeqWatermarks() + + assertTrue(tracker.getSeqWatermarks().isEmpty()) + assertFalse(tracker.isReplayInFlight()) + } + + @Test + fun `triggerReplay is no-op when watermarks are empty`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + var fetchCalled = false + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { + fetchCalled = true + SessionEventsSinceResult() + }, + emitEvent = {}, + ) + + tracker.triggerReplay() + + assertFalse(tracker.isReplayInFlight()) + testScope.advanceUntilIdle() + assertFalse(fetchCalled) + } + + @Test + fun `triggerReplay arms hold synchronously and sets replay in flight before coroutine runs`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val deferred = CompletableDeferred() + val emitted = mutableListOf() + + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { deferred.await() }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setSeqWatermark("s1", 5) + + // Arms hold synchronously + tracker.triggerReplay() + + assertTrue(tracker.isReplayInFlight()) + + // Calling triggerReplay again while in flight is ignored + tracker.triggerReplay() + assertTrue(tracker.isReplayInFlight()) + + // A live event arriving now for s1 must be parked into hold without advancing watermark + val liveMsg = WsEvent.MessageToken(token = "live", sessionId = "s1") + tracker.acceptLiveEvent("s1", 8, liveMsg) + + // Watermark has NOT advanced to 8 because replay is in flight and event is held + assertEquals(5, tracker.getSeqWatermarks()["s1"]) + assertTrue(emitted.isEmpty()) + + // Complete replay + deferred.complete(SessionEventsSinceResult()) + testScope.advanceUntilIdle() + + // Replay completed, held event released and emitted, watermark advanced + assertFalse(tracker.isReplayInFlight()) + assertEquals(listOf(liveMsg), emitted) + assertEquals(8, tracker.getSeqWatermarks()["s1"]) + } + + @Test + fun `isDuplicate drops equal or older seq and accepts newer seq`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = {}, + ) + + tracker.setSeqWatermark("s1", 10) + + // Same seq: duplicate + assertTrue(tracker.isDuplicate("s1", 10)) + // Older seq: duplicate + assertTrue(tracker.isDuplicate("s1", 5)) + // Newer seq: not duplicate + assertFalse(tracker.isDuplicate("s1", 11)) + // Unknown session defaults to seq 0 + assertFalse(tracker.isDuplicate("s2", 1)) + assertTrue(tracker.isDuplicate("s2", 0)) + } + + @Test + fun `acceptLiveEvent without in-flight replay emits event and advances watermark`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val emitted = mutableListOf() + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setSeqWatermark("s1", 10) + val event = WsEvent.MessageToken(token = "tok", sessionId = "s1") + + // Duplicate seq: dropped + tracker.acceptLiveEvent("s1", 10, event) + assertTrue(emitted.isEmpty()) + assertEquals(10, tracker.getSeqWatermarks()["s1"]) + + // Newer seq: emitted and updates watermark + tracker.acceptLiveEvent("s1", 12, event) + assertEquals(listOf(event), emitted) + assertEquals(12, tracker.getSeqWatermarks()["s1"]) + } + + @Test + fun `replay events are emitted in provided order and advance watermarks`() = + runTest { + val emitted = mutableListOf() + val event1 = + jsonElement( + """{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"a"}}""", + ) + val event2 = + jsonElement( + """{"type":"message.token","session_id":"s1","seq":7,"payload":{"text":"b"}}""", + ) + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { + SessionEventsSinceResult( + events = listOf(event1, event2), + ) + }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setSeqWatermark("s1", 5) + tracker.fetchReplayForTest() + + assertEquals(2, emitted.size) + assertEquals("a", (emitted[0] as WsEvent.MessageToken).token) + assertEquals("b", (emitted[1] as WsEvent.MessageToken).token) + assertEquals(7, tracker.getSeqWatermarks()["s1"]) + assertFalse(tracker.isReplayInFlight()) + } + + @Test + fun `replay events with seq at or below watermark are dropped`() = + runTest { + val emitted = mutableListOf() + val oldEvent = + jsonElement( + """{"type":"message.token","session_id":"s1","seq":5,"payload":{"text":"old"}}""", + ) + val newEvent = + jsonElement( + """{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"new"}}""", + ) + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { + SessionEventsSinceResult( + events = listOf(oldEvent, newEvent), + ) + }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setSeqWatermark("s1", 5) + tracker.fetchReplayForTest() + + assertEquals(1, emitted.size) + assertEquals("new", (emitted[0] as WsEvent.MessageToken).token) + assertEquals(6, tracker.getSeqWatermarks()["s1"]) + } + + @Test + fun `held live duplicates covered by replay are dropped and fresh held follow`() = + runTest { + val deferred = CompletableDeferred() + val emitted = mutableListOf() + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { deferred.await() }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setSeqWatermark("s1", 5) + tracker.triggerReplay() + + // Live event 6 arrives while replay in flight + val live6 = WsEvent.MessageToken(token = "live6", sessionId = "s1") + val live7 = WsEvent.MessageToken(token = "live7", sessionId = "s1") + tracker.acceptLiveEvent("s1", 6, live6) + tracker.acceptLiveEvent("s1", 7, live7) + + // Replay also returns event 6 + val replay6 = + jsonElement( + """{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"replay6"}}""", + ) + deferred.complete( + SessionEventsSinceResult( + events = listOf(replay6), + ), + ) + + testScheduler.advanceUntilIdle() + + // Replay 6 is emitted first, advancing watermark to 6. + // Held live 6 (seq 6 <= 6) is dropped as duplicate. + // Held live 7 (seq 7 > 6) is emitted and advances watermark to 7. + assertEquals(2, emitted.size) + assertEquals("replay6", (emitted[0] as WsEvent.MessageToken).token) + assertEquals("live7", (emitted[1] as WsEvent.MessageToken).token) + assertEquals(7, tracker.getSeqWatermarks()["s1"]) + assertFalse(tracker.isReplayInFlight()) + } + + @Test + fun `epoch change clears all watermarks updates epoch and requests transcript resync`() = + runTest { + val emitted = mutableListOf() + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { + SessionEventsSinceResult( + epoch = "epoch-2", + latestSeq = 50, + ) + }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setReplayEpochForTest("epoch-1") + tracker.setSeqWatermark("s1", 10) + tracker.setSeqWatermark("s2", 20) + + tracker.fetchReplayForTest() + + // Watermarks wiped + assertTrue(tracker.getSeqWatermarks().isEmpty()) + assertEquals(1, emitted.size) + assertEquals(WsEvent.TranscriptResyncRequired("s1"), emitted[0]) + } + + @Test + fun `empty or null epoch in result does not trigger epoch change`() = + runTest { + val emitted = mutableListOf() + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { + SessionEventsSinceResult( + epoch = "", + events = emptyList(), + ) + }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setReplayEpochForTest("epoch-1") + tracker.setSeqWatermark("s1", 10) + + tracker.fetchReplayForTest() + + assertEquals(10, tracker.getSeqWatermarks()["s1"]) + assertTrue(emitted.isEmpty()) + } + + @Test + fun `truncated resync fast-forwards watermark to latestSeq when epoch is same`() = + runTest { + val emitted = mutableListOf() + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { + SessionEventsSinceResult( + truncated = true, + latestSeq = 100, + epoch = "epoch-1", + ) + }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setReplayEpochForTest("epoch-1") + tracker.setSeqWatermark("s1", 10) + + tracker.fetchReplayForTest() + + // Fast-forwards to latestSeq and emits TranscriptResyncRequired + assertEquals(100, tracker.getSeqWatermarks()["s1"]) + assertEquals(listOf(WsEvent.TranscriptResyncRequired("s1")), emitted) + } + + @Test + fun `replay failure triggers resync invokes onReplayFailure and releases held events`() = + runTest { + val deferred = CompletableDeferred() + val emitted = mutableListOf() + val failedSessions = mutableListOf>() + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { + deferred.await() + }, + emitEvent = { emitted.add(it) }, + onReplayFailure = { sid, ex -> failedSessions.add(sid to ex) }, + ) + + tracker.setSeqWatermark("s1", 10) + tracker.triggerReplay() + + // Park a live event while waiting + val liveEvent = WsEvent.MessageToken(token = "held", sessionId = "s1") + tracker.acceptLiveEvent("s1", 15, liveEvent) + + // Fail replay + val testException = IllegalStateException("network error") + deferred.completeExceptionally(testException) + + testScheduler.advanceUntilIdle() + + // Failure emitted TranscriptResyncRequired, reported to callback, and released held events + assertEquals(1, failedSessions.size) + assertEquals("s1", failedSessions[0].first) + assertTrue(failedSessions[0].second is IllegalStateException) + assertEquals("network error", failedSessions[0].second.message) + + assertEquals(2, emitted.size) + assertEquals(WsEvent.TranscriptResyncRequired("s1"), emitted[0]) + assertEquals(liveEvent, emitted[1]) + assertEquals(15, tracker.getSeqWatermarks()["s1"]) + assertFalse(tracker.isReplayInFlight()) + } + + @Test + fun `MessageComplete event uses resolveStoredSessionId injection`() = + runTest { + val emitted = mutableListOf() + val msgCompleteJson = + jsonElement( + """{"type":"message.complete","session_id":"wire-sid","payload":{"text":"done"}}""", + ) + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { + SessionEventsSinceResult( + events = listOf(msgCompleteJson), + ) + }, + emitEvent = { emitted.add(it) }, + resolveStoredSessionId = { wireSid -> "resolved-$wireSid" }, + ) + + tracker.setSeqWatermark("wire-sid", 5) + tracker.fetchReplayForTest() + + assertEquals(1, emitted.size) + val complete = emitted[0] as WsEvent.MessageComplete + assertEquals("done", complete.text) + assertEquals("wire-sid", complete.sessionId) + assertEquals("resolved-wire-sid", complete.storedSessionId) + } + + @Test + fun `updateEpoch clears watermarks when epoch changes`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = {}, + ) + + tracker.setReplayEpochForTest("epoch-1") + tracker.setSeqWatermark("s1", 10) + + // Same epoch does not clear + tracker.updateEpoch("epoch-1") + assertEquals(10, tracker.getSeqWatermarks()["s1"]) + + // Empty / null does not clear + tracker.updateEpoch(null) + tracker.updateEpoch("") + assertEquals(10, tracker.getSeqWatermarks()["s1"]) + + // Different epoch clears watermarks + tracker.updateEpoch("epoch-2") + assertTrue(tracker.getSeqWatermarks().isEmpty()) + } + + @Test + fun `clear cancels in-flight job and resets all state`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val deferred = CompletableDeferred() + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { deferred.await() }, + emitEvent = {}, + ) + + tracker.setSeqWatermark("s1", 10) + tracker.triggerReplay() + assertTrue(tracker.isReplayInFlight()) + + tracker.clear() + + assertFalse(tracker.isReplayInFlight()) + assertTrue(tracker.getSeqWatermarks().isEmpty()) + } + + @Test + fun `acceptLiveEvent with eventFactory drops duplicate seq without invoking factory or emitting`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val emitted = mutableListOf() + var factoryInvoked = false + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setSeqWatermark("s1", 10) + + // Duplicate seq <= watermark must not invoke factory nor emit + tracker.acceptLiveEvent(sessionId = "s1", seq = 10) { + factoryInvoked = true + WsEvent.MessageToken(token = "duplicate", sessionId = "s1") + } + + assertFalse(factoryInvoked) + assertTrue(emitted.isEmpty()) + assertEquals(10, tracker.getSeqWatermarks()["s1"]) + } + + @Test + fun `acceptLiveEvent with eventFactory invokes factory once and preserves single-check ordering`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val emitted = mutableListOf() + val invocationCount = AtomicInteger(0) + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setSeqWatermark("s1", 10) + val expectedEvent = WsEvent.MessageToken(token = "fresh", sessionId = "s1") + + // Factory intentionally injects an interleaved watermark advance (e.g. simulating race or side effect). + // Under single-check ordering, the initial pre-parse check (seq 11 > 10) succeeds, calls factory exactly once, + // and proceeds to emit and advance watermark without silently dropping due to an unexpected second check. + tracker.acceptLiveEvent(sessionId = "s1", seq = 11) { + invocationCount.incrementAndGet() + tracker.setSeqWatermark("s1", 99) + expectedEvent + } + + assertEquals(1, invocationCount.get()) + assertEquals(listOf(expectedEvent), emitted) + assertEquals(11, tracker.getSeqWatermarks()["s1"]) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RpcChannelTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RpcChannelTest.kt new file mode 100644 index 00000000..5e1304a8 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RpcChannelTest.kt @@ -0,0 +1,790 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.ws.contract.RpcMethod +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RpcChannelTest { + private val testJson = + Json { + ignoreUnknownKeys = true + encodeDefaults = false + } + + @Serializable + private data class SampleRequestParams( + val message: String, + val count: Int? = null, + ) + + @Serializable + private data class SampleResult( + val status: String, + val total: Int, + ) + + private val sampleMethod = + RpcMethod("test.sample", SampleRequestParams.serializer(), SampleResult.serializer()) + + private class FakeGateway( + scope: CoroutineScope, + json: Json, + outboundLock: Any = Any(), + val onResultCallback: (String) -> Unit = {}, + val onErrorCallback: (String) -> Unit = {}, + resolveStoredSessionId: (String?) -> String? = { it }, + val logParseFailureCallback: (Exception) -> Unit = {}, + decorateParams: (String, Map) -> Map = { _, p -> p }, + ) { + val sentRequests = mutableListOf() + val sentFrames = mutableListOf() + val removedQueuedIds = mutableListOf() + val rejectedLogs = mutableListOf>() + var idleCheckCount = 0 + var sendFrameReturnValue = true + + var immediateResolveOnSend: ((id: String, onSent: ((String) -> Unit)?) -> Unit)? = null + + val channel: RpcChannel = + RpcChannel( + scope = scope, + json = json, + sendRequest = { method, params, onSent -> + val id = "req-${sentRequests.size + 1}" + sentRequests.add(SentRequestRecord(id, method, params)) + onSent?.invoke(id) + immediateResolveOnSend?.invoke(id, onSent) + id + }, + sendFrame = { frame -> + sentFrames.add(frame) + sendFrameReturnValue + }, + removeQueuedMessage = { id -> + removedQueuedIds.add(id) + }, + onIdleCheck = { + idleCheckCount++ + }, + logRejected = { method, id -> + rejectedLogs.add(method to id) + }, + outboundLock = outboundLock, + onResult = onResultCallback, + onError = onErrorCallback, + resolveStoredSessionId = resolveStoredSessionId, + logParseFailure = logParseFailureCallback, + decorateParams = decorateParams, + ) + + data class SentRequestRecord( + val id: String, + val method: String, + val params: Map, + ) + } + + @Test + fun nextIdProducesIncreasingNonBlankStrings() { + val gateway = FakeGateway(CoroutineScope(CompletableDeferred()), testJson) + val firstId = gateway.channel.nextId() + val secondId = gateway.channel.nextId() + + assertEquals("1", firstId) + assertTrue(firstId.isNotBlank()) + assertTrue(secondId.isNotBlank()) + val firstNum = firstId.toLong() + val secondNum = secondId.toLong() + assertTrue(secondNum > firstNum) + assertEquals(firstNum + 1L, secondNum) + } + + @Test + fun requestSuccessResolvesDeferredAndCleansUpInOrder() = + runTest { + val gateway = FakeGateway(this, testJson) + val deferred = gateway.channel.request("user.ping", mapOf("key" to "value")) + val id = gateway.sentRequests.first().id + + assertTrue(gateway.channel.hasPendingCalls()) + assertTrue(gateway.channel.pendingCallIdsForTest().contains(id)) + assertNotNull(gateway.channel.pendingCallTimeoutForTest(id)) + + val dummyResult = JsonPrimitive("pong") + gateway.channel.resolvePending(id, dummyResult, null) + + val resolved = deferred.await() + assertEquals(dummyResult, resolved) + assertFalse(gateway.channel.hasPendingCalls()) + assertEquals(listOf(id), gateway.removedQueuedIds) + assertEquals(1, gateway.idleCheckCount) + assertNull(gateway.channel.pendingCallTimeoutForTest(id)) + } + + @Test + fun requestErrorResolvesWithHermesRpcExceptionAndErrorData() = + runTest { + val gateway = FakeGateway(this, testJson) + val deferred = gateway.channel.request("user.fail") + val id = gateway.sentRequests.first().id + + val errorData = buildJsonObject { put("reason", "auth_failed") } + val rpcError = JsonRpcError(code = 401, message = "Unauthorized", data = errorData) + + gateway.channel.resolvePending(id, null, rpcError) + + try { + deferred.await() + fail("Expected HermesRpcException") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals("Unauthorized", e.message) + assertEquals(401, e.code) + assertEquals(errorData, e.data) + } + + assertFalse(gateway.channel.hasPendingCalls()) + assertEquals(listOf(id), gateway.removedQueuedIds) + assertEquals(1, gateway.idleCheckCount) + } + + @Test + fun requestTimeoutFailsWithExactMessageAndCodeMinusOne() = + runTest { + val gateway = FakeGateway(this, testJson) + val timeoutMs = 5_000L + val deferred = gateway.channel.request("agent.turn", timeoutMs = timeoutMs) + val id = gateway.sentRequests.first().id + + runCurrent() + advanceTimeBy(4_999L) + runCurrent() + assertTrue(gateway.channel.hasPendingCalls()) + assertFalse(deferred.isCompleted) + + advanceTimeBy(1L) + runCurrent() + + assertTrue(deferred.isCompleted) + assertFalse(gateway.channel.hasPendingCalls()) + assertEquals(listOf(id), gateway.removedQueuedIds) + assertEquals(1, gateway.idleCheckCount) + + try { + deferred.await() + fail("Expected HermesRpcException on timeout") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals("Request timed out: agent.turn", e.message) + assertEquals(-1, e.code) + assertNull(e.data) + } + } + + @Test + fun deferredCancellationCancelsTimerRemovesQueuedAndTriggersIdle() = + runTest { + val gateway = FakeGateway(this, testJson) + val deferred = gateway.channel.request("slow.method", timeoutMs = 60_000L) + val id = gateway.sentRequests.first().id + + val timeoutJob = gateway.channel.pendingCallTimeoutForTest(id) + assertNotNull(timeoutJob) + assertFalse(timeoutJob!!.isCancelled) + + deferred.cancel() + runCurrent() + + assertTrue(deferred.isCancelled) + assertFalse(gateway.channel.hasPendingCalls()) + assertTrue(timeoutJob.isCancelled) + assertEquals(listOf(id), gateway.removedQueuedIds) + assertEquals(1, gateway.idleCheckCount) + + // Advancing past original timeout should do nothing further + advanceTimeBy(70_000L) + runCurrent() + assertEquals(1, gateway.idleCheckCount) + } + + @Test + fun suppressionFlagReflectedInIsErrorEventSuppressed() = + runTest { + val gateway = FakeGateway(this, testJson) + gateway.channel.request("normal.call", suppressErrorEvent = false) + val normalId = gateway.sentRequests[0].id + + gateway.channel.request("silent.call", suppressErrorEvent = true) + val silentId = gateway.sentRequests[1].id + + assertFalse(gateway.channel.isErrorEventSuppressed(normalId)) + assertTrue(gateway.channel.isErrorEventSuppressed(silentId)) + assertFalse(gateway.channel.isErrorEventSuppressed("non-existent-id")) + } + + @Test + fun rejectAllPendingFailsAllCallsWithProvidedExceptionAndNoIdleCheck() = + runTest { + val gateway = FakeGateway(this, testJson) + val deferred1 = gateway.channel.request("method.one") + val deferred2 = gateway.channel.request("method.two") + val id1 = gateway.sentRequests[0].id + val id2 = gateway.sentRequests[1].id + + val customException = HermesWsClient.HermesRpcException("Socket terminated abruptly", code = 1006) + + gateway.channel.rejectAllPending(customException) + + assertFalse(gateway.channel.hasPendingCalls()) + // Verified contract: rejectAllPending performs NO onIdleCheck + assertEquals(0, gateway.idleCheckCount) + assertEquals(setOf(id1, id2), gateway.removedQueuedIds.toSet()) + assertEquals(setOf("method.one" to id1, "method.two" to id2), gateway.rejectedLogs.toSet()) + + try { + deferred1.await() + fail("deferred1 should have failed") + } catch (e: HermesWsClient.HermesRpcException) { + assertSame(customException, e) + } + + try { + deferred2.await() + fail("deferred2 should have failed") + } catch (e: HermesWsClient.HermesRpcException) { + assertSame(customException, e) + } + } + + @Test + fun rejectAllPendingDefaultExceptionMatchesExpectedMessage() = + runTest { + val gateway = FakeGateway(this, testJson) + val deferred = gateway.channel.request("some.call") + + gateway.channel.rejectAllPending() + + try { + deferred.await() + fail("Expected default exception") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals("Connection lost — request cancelled", e.message) + } + } + + @Test + fun immediateResolutionDuringOnSentHandledSafely() = + runTest { + val gateway = FakeGateway(this, testJson) + gateway.immediateResolveOnSend = { id, _ -> + gateway.channel.resolvePending(id, "instant-result", null) + } + + val deferred = gateway.channel.request("instant.call") + val id = gateway.sentRequests.first().id + + assertTrue(deferred.isCompleted) + assertEquals("instant-result", deferred.await()) + assertFalse(gateway.channel.hasPendingCalls()) + assertEquals(listOf(id), gateway.removedQueuedIds) + assertEquals(1, gateway.idleCheckCount) + } + + @Test + fun typedCallSerializesParamsOmittingDefaultsAndDecodesResult() = + runTest { + val gateway = FakeGateway(this, testJson) + + val job = + async { + gateway.channel.call( + sampleMethod, + SampleRequestParams(message = "hello", count = null), + ) + } + + runCurrent() + assertEquals(1, gateway.sentRequests.size) + val sent = gateway.sentRequests.first() + assertEquals("test.sample", sent.method) + // encodeDefaults = false means count (null) is omitted from params + val encodedParams = + gateway.channel.encodeParams( + sampleMethod, + SampleRequestParams(message = "hello", count = null), + ) + assertFalse(encodedParams.containsKey("count")) + assertTrue(encodedParams.containsKey("message")) + + // Resolve pending with raw json representation + val rawResult = + buildJsonObject { + put("status", "ok") + put("total", 42) + put("extra_ignored_key", "bonus") + } + gateway.channel.resolvePending(sent.id, rawResult, null) + + val result = job.await() + assertEquals("ok", result.status) + assertEquals(42, result.total) + } + + @Test + fun typedCallCancellationPropagatesAndCancelsDeferred() = + runTest { + val gateway = FakeGateway(this, testJson) + + val job = + launch { + gateway.channel.call( + sampleMethod, + SampleRequestParams(message = "cancel-me"), + ) + } + + runCurrent() + val id = gateway.sentRequests.first().id + assertTrue(gateway.channel.hasPendingCalls()) + + job.cancel() + runCurrent() + + assertFalse(gateway.channel.hasPendingCalls()) + assertEquals(listOf(id), gateway.removedQueuedIds) + assertEquals(1, gateway.idleCheckCount) + } + + @Test + fun rawJsonNumericFidelityPreservedIntoResolve() = + runTest { + val gateway = FakeGateway(this, testJson) + + val deferred = + gateway.channel.requestTyped( + sampleMethod, + SampleRequestParams(message = "precision"), + ) + val id = gateway.sentRequests.first().id + + // Map with integer-value double, e.g. 177.0 -> preserves int semantics in toJsonElement + val mapResult = mapOf("status" to "precise", "total" to 177.0) + gateway.channel.resolvePending(id, mapResult, null) + + val raw = deferred.await() + val jsonElement = raw.toJsonElement() as JsonObject + assertEquals(177, jsonElement["total"]?.jsonPrimitive?.int) + + // Direct decode with injected channel json to verify round-trip + val decoded = testJson.decodeFromJsonElement(sampleMethod.result, jsonElement) + assertEquals(177, decoded.total) + assertEquals("precise", decoded.status) + } + + @Test + fun respondToServerRequestFormatsExactJsonRpcResponseAndPropagatesSink() { + val gateway = FakeGateway(CoroutineScope(CompletableDeferred()), testJson) + val serverReqId = "srv-999" + val payload = buildJsonObject { put("pong", true) } + + gateway.sendFrameReturnValue = true + val result = gateway.channel.respondToServerRequest(serverReqId, payload) + assertTrue(result) + assertEquals(1, gateway.sentFrames.size) + + val frame = testJson.decodeFromString(gateway.sentFrames.first()) + assertEquals("2.0", frame["jsonrpc"]?.jsonPrimitive?.content) + assertEquals(serverReqId, frame["id"]?.jsonPrimitive?.content) + assertEquals(payload, frame["result"]) + assertFalse(frame.containsKey("method")) + assertFalse(frame.containsKey("params")) + + // Blank id returns false immediately without sending frame + val blankResult = gateway.channel.respondToServerRequest(" ", payload) + assertFalse(blankResult) + assertEquals(1, gateway.sentFrames.size) + + // Sink returning false is propagated + gateway.sendFrameReturnValue = false + val falseResult = gateway.channel.respondToServerRequest("srv-1000", payload) + assertFalse(falseResult) + assertEquals(2, gateway.sentFrames.size) + } + + @Test + fun respondToServerRequestErrorFormatsExactJsonRpcErrorAndPropagatesSink() { + val gateway = FakeGateway(CoroutineScope(CompletableDeferred()), testJson) + val serverReqId = "srv-888" + + gateway.sendFrameReturnValue = true + val result = gateway.channel.respondToServerRequestError(serverReqId, -32601, "Method not found") + assertTrue(result) + assertEquals(1, gateway.sentFrames.size) + + val frame = testJson.decodeFromString(gateway.sentFrames.first()) + assertEquals("2.0", frame["jsonrpc"]?.jsonPrimitive?.content) + assertEquals(serverReqId, frame["id"]?.jsonPrimitive?.content) + assertFalse(frame.containsKey("result")) + val errorObj = frame["error"] as JsonObject + assertEquals(-32601, errorObj["code"]?.jsonPrimitive?.int) + assertEquals("Method not found", errorObj["message"]?.jsonPrimitive?.content) + + // Blank id returns false without sending frame + assertFalse(gateway.channel.respondToServerRequestError("", -1, "Bad")) + assertEquals(1, gateway.sentFrames.size) + + // Sink returning false is propagated + gateway.sendFrameReturnValue = false + val falseResult = gateway.channel.respondToServerRequestError("srv-889", -32000, "Error") + assertFalse(falseResult) + assertEquals(2, gateway.sentFrames.size) + } + + @Test + fun createRequestDecoratesOnceAllocatesIdAndEncodesOmittingDefaults() { + var decorateCount = 0 + val gateway = + FakeGateway( + scope = CoroutineScope(CompletableDeferred()), + json = testJson, + decorateParams = { method, params -> + decorateCount++ + params + ("decoratedMethod" to method) + }, + ) + + val request = gateway.channel.createRequest("test.action", mapOf("key" to "value")) + assertEquals("1", request.id) + assertEquals("test.action", request.method) + assertEquals(1, decorateCount) + assertEquals(JsonPrimitive("value"), request.params["key"]) + assertEquals(JsonPrimitive("test.action"), request.params["decoratedMethod"]) + + val encoded = gateway.channel.encodeRequest(request) + // Injected json with encodeDefaults = false omits default jsonrpc ("2.0") + assertFalse(encoded.contains("\"jsonrpc\"")) + assertTrue(encoded.contains("\"id\":\"1\"") || encoded.contains("\"id\": \"1\"")) + assertTrue(encoded.contains("\"method\":\"test.action\"") || encoded.contains("\"method\": \"test.action\"")) + + // An empty params map is omitted by encodeRequest under encodeDefaults = false + val plainGateway = FakeGateway(CoroutineScope(CompletableDeferred()), testJson) + val emptyParamsReq = plainGateway.channel.createRequest("test.empty", emptyMap()) + val encodedEmpty = plainGateway.channel.encodeRequest(emptyParamsReq) + assertFalse(encodedEmpty.contains("\"params\"")) + } + + @Test + fun handleIncomingFrameOrderRawJsonResolvedBeforeEventEmittedAndDoubleOnResult() = + runTest { + val onResultCalls = mutableListOf() + val events = mutableListOf() + val gateway = + FakeGateway( + scope = this, + json = testJson, + onResultCallback = { onResultCalls.add(it) }, + ) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { events.add(it) }, + ) + + val deferred = gateway.channel.request("client.calc") + val id = gateway.sentRequests.first().id + + var rawResolvedAtEventTime: Boolean? = null + var onResultCallsAtEventTime = 0 + val emitSink: (WsEvent) -> Unit = { event -> + events.add(event) + rawResolvedAtEventTime = deferred.isCompleted + onResultCallsAtEventTime = onResultCalls.size + } + + val incomingFrame = """{"jsonrpc":"2.0","id":"$id","result":{"ans":42.0}}""" + gateway.channel.handleIncomingFrame(incomingFrame, replayTracker, emitSink) + + // onResult is invoked twice baseline: once before raw resolve, and again before parsed RpcResult resolution + assertEquals(2, onResultCalls.size) + assertEquals(listOf(id, id), onResultCalls) + assertEquals(2, onResultCallsAtEventTime) + + // Raw deferred was settled BEFORE emitEvent was called + assertEquals(true, rawResolvedAtEventTime) + assertTrue(deferred.isCompleted) + + // Numeric fidelity preserved on result + val rawResult = deferred.await() + assertTrue(rawResult is JsonObject) + val rawObj = rawResult as JsonObject + assertEquals(JsonPrimitive(42.0), rawObj["ans"]) + + // Event emitted was RpcResult + assertEquals(1, events.size) + val rpcResult = events.first() as WsEvent.RpcResult + assertEquals(id, rpcResult.id) + } + + @Test + fun handleIncomingFrameRpcErrorInvokesCallbackAndRespectsSuppression() = + runTest { + val onErrorCalls = mutableListOf() + val events = mutableListOf() + val gateway = + FakeGateway( + scope = this, + json = testJson, + onErrorCallback = { onErrorCalls.add(it) }, + ) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { events.add(it) }, + ) + + // 1. Normal unsuppressed error + val normalDeferred = gateway.channel.request("call.normal", suppressErrorEvent = false) + val normalId = gateway.sentRequests[0].id + val errorFrame1 = + """{"jsonrpc":"2.0","id":"$normalId","error":{"code":-32603,"message":"Internal error"}}""" + gateway.channel.handleIncomingFrame(errorFrame1, replayTracker) { events.add(it) } + + assertEquals(listOf(normalId), onErrorCalls) + assertTrue(normalDeferred.isCompleted) + try { + normalDeferred.await() + fail("Expected HermesRpcException") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals(-32603, e.code) + assertEquals("Internal error", e.message) + } + assertEquals(1, events.size) + assertTrue(events.first() is WsEvent.RpcError) + assertEquals(normalId, (events.first() as WsEvent.RpcError).id) + + // 2. Suppressed error resolves deferred but emits NONE + val silentDeferred = gateway.channel.request("call.silent", suppressErrorEvent = true) + val silentId = gateway.sentRequests[1].id + val errorFrame2 = + """{"jsonrpc":"2.0","id":"$silentId","error":{"code":404,"message":"Not found"}}""" + gateway.channel.handleIncomingFrame(errorFrame2, replayTracker) { events.add(it) } + + assertEquals(listOf(normalId, silentId), onErrorCalls) + assertTrue(silentDeferred.isCompleted) + try { + silentDeferred.await() + fail("Expected HermesRpcException on silent deferred") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals(404, e.code) + } + // Event list size remains 1 (no RpcError event emitted for suppressed call) + assertEquals(1, events.size) + } + + @Test + fun handleIncomingFrameCapabilitySuppressionHidesEventUntilCleared() = + runTest { + val events = mutableListOf() + val gateway = FakeGateway(this, testJson) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { events.add(it) }, + ) + + val capId = "cap-42" + gateway.channel.registerCapabilityRequest(capId) + + val capResponseFrame = """{"jsonrpc":"2.0","id":"$capId","result":{"supported":true}}""" + gateway.channel.handleIncomingFrame(capResponseFrame, replayTracker) { events.add(it) } + + // Registered capability response is hidden from events + assertTrue(events.isEmpty()) + + // After clearing capabilities, subsequent responses with that ID are visible + gateway.channel.registerCapabilityRequest(capId) + gateway.channel.clearCapabilityRequests() + gateway.channel.handleIncomingFrame(capResponseFrame, replayTracker) { events.add(it) } + + assertEquals(1, events.size) + val rpcResult = events.first() as WsEvent.RpcResult + assertEquals(capId, rpcResult.id) + } + + @Test + fun handleIncomingFrameMalformedJsonInvokesParseFailureAndEmitsUnknown() = + runTest { + val parseFailures = mutableListOf() + val events = mutableListOf() + val gateway = + FakeGateway( + scope = this, + json = testJson, + logParseFailureCallback = { parseFailures.add(it) }, + ) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { events.add(it) }, + ) + + val malformed = "{ invalid json frame ::" + gateway.channel.handleIncomingFrame(malformed, replayTracker) { events.add(it) } + + assertEquals(1, parseFailures.size) + assertEquals(1, events.size) + val unknown = events.first() as WsEvent.Unknown + assertEquals(malformed, unknown.raw) + } + + @Test + fun handleIncomingFrameServerRequestClassifiedCorrectlyOverRpcResult() = + runTest { + val events = mutableListOf() + val gateway = FakeGateway(this, testJson) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { events.add(it) }, + ) + + // Normal srq frame with id + method should emit ServerRequest, not RpcResult + val srqFrame = """{"jsonrpc":"2.0","id":"srq-101","method":"clarify","params":{"text":"Confirm?"}}""" + gateway.channel.handleIncomingFrame(srqFrame, replayTracker) { events.add(it) } + + assertEquals(1, events.size) + val event = events.first() + assertTrue("Expected ServerRequest, got: $event", event is WsEvent.ServerRequest) + val srq = event as WsEvent.ServerRequest + assertEquals("srq-101", srq.id) + assertEquals("clarify", srq.method) + assertFalse(srq.replayed) + } + + @Test + fun handleIncomingFrameSequencedNotificationDeduplicatesViaReplayTracker() = + runTest { + val events = mutableListOf() + val gateway = FakeGateway(this, testJson) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { events.add(it) }, + ) + + val seqFrame = + """{"method":"events","params":{"type":"message.token","session_id":"s1","seq":11,"payload":{"text":"fresh"}}}""" + + // First delivery (fresh seq) emits once + gateway.channel.handleIncomingFrame(seqFrame, replayTracker) { events.add(it) } + assertEquals(1, events.size) + val tokenEvent = events.first() as WsEvent.MessageToken + assertEquals("fresh", tokenEvent.token) + assertEquals("s1", tokenEvent.sessionId) + + // Duplicate delivery (same seq 11) is dropped, emits none + gateway.channel.handleIncomingFrame(seqFrame, replayTracker) { events.add(it) } + assertEquals(1, events.size) + } + + @Test + fun handleIncomingFrameSettlesRawResultBeforeReplayingOpenRequests() = + runTest { + val order = mutableListOf() + val gateway = FakeGateway(this, testJson, onResultCallback = { order.add("result:$it") }) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = {}, + ) + val deferred = gateway.channel.request("session.resume") + val id = gateway.sentRequests.first().id + deferred.invokeOnCompletion { order.add("resolved") } + val frame = + """{"id":"$id","result":{"open_requests":[{"id":"srq-open","method":"clarify","params":{"text":"replayed"}}]}}""" + + gateway.channel.handleIncomingFrame(frame, replayTracker) { event -> + assertTrue("Raw deferred must settle before either event", deferred.isCompleted) + when (event) { + is WsEvent.ServerRequest -> { + assertTrue(event.replayed) + assertEquals("clarify", event.method) + assertEquals("replayed", event.params["text"]) + order.add("server:${event.id}") + } + + is WsEvent.RpcResult -> { + order.add("rpc:${event.id}") + } + + else -> { + fail("Unexpected event: $event") + } + } + } + + assertEquals(listOf("result:$id", "resolved", "server:srq-open", "result:$id", "rpc:$id"), order) + assertTrue(deferred.await() is JsonObject) + assertFalse(gateway.channel.hasPendingCalls()) + } + + @Test + fun handleIncomingFrameDecoratesStoredSessionIdOnMessageComplete() = + runTest { + val events = mutableListOf() + val gateway = + FakeGateway( + scope = this, + json = testJson, + resolveStoredSessionId = { sid -> "stored-$sid" }, + ) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { events.add(it) }, + ) + + val completeFrame = + """{"method":"events","params":{"type":"message.complete","session_id":"active-session","payload":{"text":"done","reasoning":"reason"}}}""" + gateway.channel.handleIncomingFrame(completeFrame, replayTracker) { events.add(it) } + + assertEquals(1, events.size) + val completeEvent = events.first() as WsEvent.MessageComplete + assertEquals("done", completeEvent.text) + assertEquals("reason", completeEvent.reasoning) + assertEquals("active-session", completeEvent.sessionId) + assertEquals("stored-active-session", completeEvent.storedSessionId) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/WsTransportTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/WsTransportTest.kt new file mode 100644 index 00000000..fbb11bc4 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/WsTransportTest.kt @@ -0,0 +1,489 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.NetworkMonitor +import com.m57.hermescontrol.data.session.ActiveSessionHolder +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.Response +import okhttp3.WebSocket +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class WsTransportTest { + private val testJson = + Json { + ignoreUnknownKeys = true + encodeDefaults = false + } + + private class Fixture( + val scope: CoroutineScope, + json: Json, + nowMs: () -> Long = { + if (scope is TestScope) { + scope.testScheduler.currentTime + } else { + System.nanoTime() / 1_000_000L + } + }, + ) { + val outboundLock = Any() + val rawMessages = mutableListOf() + val replayEmittedEvents = mutableListOf() + val reconnectPolicy = ReconnectPolicy() + + val rpcSentFrames = mutableListOf() + val rpcRemovedQueueIds = mutableListOf() + var rpcIdleChecks = 0 + + var transportRef: WsTransport? = null + private val rpcIdCounter = AtomicInteger(1) + + val connectionHealth = + ConnectionHealth( + scope = scope, + isConnected = { transportRef?.isConnected ?: false }, + pingRequest = { /* ping fake unit */ }, + cancelSocket = { transportRef?.cancelSocket() }, + pendingReply = { transportRef?.pendingReply ?: false }, + nowMs = nowMs, + ) + + val replayTracker = + ReplayTracker( + scope = scope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { replayEmittedEvents.add(it) }, + ) + + val rpcChannel = + RpcChannel( + scope = scope, + json = json, + sendRequest = { method, params, onSent -> + val id = "rpc-${rpcIdCounter.getAndIncrement()}" + val req = + rpcChannelRef?.createRequest(method, params)?.copy(id = id) + ?: JsonRpcRequest(id = id, method = method) + transportRef?.send(req, onSent) ?: id + }, + sendFrame = { frame -> + rpcSentFrames.add(frame) + transportRef?.sendDirectFrame(frame) ?: false + }, + removeQueuedMessage = { id -> + rpcRemovedQueueIds.add(id) + transportRef?.removeQueuedMessage(id) + }, + onIdleCheck = { + rpcIdleChecks++ + transportRef?.disconnectIfIdleInBackground() + }, + outboundLock = outboundLock, + onResult = { id -> transportRef?.onRpcResult(id) }, + onError = { id -> transportRef?.onRpcError(id) }, + ).also { rpcChannelRef = it } + + val transport = + WsTransport( + scope = scope, + outboundLock = outboundLock, + reconnectPolicy = reconnectPolicy, + connectionHealth = connectionHealth, + replayTracker = replayTracker, + rpcChannel = rpcChannel, + onRawMessage = { rawMessages.add(it) }, + ).also { transportRef = it } + + companion object { + private var rpcChannelRef: RpcChannel? = null + } + } + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + ActiveSessionHolder.clear() + + mockkObject(AuthManager) + every { AuthManager.isAutoReconnect() } returns false + + mockkObject(NetworkMonitor) + every { NetworkMonitor.isConnected } returns MutableStateFlow(true) + } + + @After + fun tearDown() { + ActiveSessionHolder.clear() + unmockkAll() + } + + @Test + fun sendAcceptCallbackRunsUnderOutboundLockAfterSocketAccept() = + runTest { + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + every { socket.send(any()) } returns true + + fixture.transport.connectedForTest.set(true) + fixture.transport.webSocketForTest = socket + + var callbackRan = false + var callbackThreadHoldsLock = false + val request = JsonRpcRequest(id = "req-1", method = "session.list") + + val id = + fixture.transport.send( + request = request, + onSent = { + callbackRan = true + callbackThreadHoldsLock = Thread.holdsLock(fixture.outboundLock) + }, + ) + + assertEquals("req-1", id) + assertTrue(callbackRan) + assertTrue(callbackThreadHoldsLock) + verify(exactly = 1) { socket.send(any()) } + assertTrue(fixture.transport.messageQueueForTest.isEmpty()) + } + + @Test + fun rejectedRetryableSendEnqueuesAndCancelDoesNotAutoRedialWhenAutoReconnectDisabled() = + runTest { + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + every { socket.send(any()) } returns false + every { socket.queueSize() } returns 0L + + fixture.transport.connectedForTest.set(true) + fixture.transport.intentionalCloseForTest.set(false) + fixture.transport.connectionGenerationForTest.set(1) + fixture.transport.webSocketForTest = socket + + var onSentCalled = false + var callbackThreadHoldsLock = false + val request = JsonRpcRequest(id = "req-queued", method = "prompt.submit") + + fixture.transport.send( + request = request, + onSent = { + onSentCalled = true + callbackThreadHoldsLock = Thread.holdsLock(fixture.outboundLock) + }, + queueIfDisconnected = true, + ) + + // When socket rejects, request is pushed back into retryable queue + val queue = fixture.transport.messageQueueForTest + assertEquals(1, queue.size) + assertTrue(queue.peek()?.contains("req-queued") == true) + assertTrue(onSentCalled) + assertTrue(callbackThreadHoldsLock) + assertFalse(fixture.transport.isConnected) + assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value) + assertNull(fixture.transport.reconnectJobForTest) + verify(exactly = 1) { socket.cancel() } + } + + @Test + fun staleOnMessageIgnoredWhileCurrentGenerationFrameUpdatesHealthAndRawCallback() = + runTest { + val fixture = + Fixture( + scope = this, + json = testJson, + nowMs = { testScheduler.currentTime }, + ) + val currentSocket = mockk(relaxed = true) + val staleSocket = mockk(relaxed = true) + + fixture.transport.connectedForTest.set(true) + fixture.transport.connectionGenerationForTest.set(2) + fixture.transport.webSocketForTest = currentSocket + + val initialTimestamp = fixture.connectionHealth.lastPongTimestamp + assertEquals(0L, initialTimestamp) + + val staleListener = fixture.transport.createListenerForTest(1) + val currentListener = fixture.transport.createListenerForTest(2) + + advanceTimeBy(1_000L) + // Message from stale listener/generation should be discarded + staleListener.onMessage(staleSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"old\"}") + staleListener.onMessage(currentSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"old-mismatched-gen\"}") + assertTrue(fixture.rawMessages.isEmpty()) + assertEquals(0L, fixture.connectionHealth.lastPongTimestamp) + + advanceTimeBy(1_000L) + // Message from current generation but mismatched socket should be discarded + currentListener.onMessage(staleSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"wrong-socket\"}") + assertTrue(fixture.rawMessages.isEmpty()) + assertEquals(0L, fixture.connectionHealth.lastPongTimestamp) + + advanceTimeBy(1_000L) + val expectedTimestamp = testScheduler.currentTime + // Matching generation and matching active socket passes through and updates health + currentListener.onMessage(currentSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"valid\"}") + assertEquals(listOf("{\"jsonrpc\":\"2.0\",\"id\":\"valid\"}"), fixture.rawMessages) + assertEquals(expectedTimestamp, fixture.connectionHealth.lastPongTimestamp) + } + + @Test + fun staleOnOpenClosesSupersededWithoutChangingOwnershipOrFlushingQueue() = + runTest { + val fixture = Fixture(this, testJson) + val staleSocket = mockk(relaxed = true) + val currentSocket = mockk(relaxed = true) + val response = mockk(relaxed = true) + + fixture.transport.intentionalCloseForTest.set(false) + fixture.transport.connectionGenerationForTest.set(2) + fixture.transport.webSocketForTest = currentSocket + fixture.transport.messageQueueForTest.add("{\"jsonrpc\":\"2.0\",\"id\":\"queued-1\"}") + + val staleListener = fixture.transport.createListenerForTest(1) + staleListener.onOpen(staleSocket, response) + + // Stale onOpen must close the stale socket with 1000 ("Superseded") + verify(exactly = 1) { staleSocket.close(1000, "Superseded") } + // Must not change active socket or flush the queue on stale socket + verify(exactly = 0) { staleSocket.send(any()) } + assertEquals(currentSocket, fixture.transport.webSocketForTest) + assertEquals(1, fixture.transport.messageQueueForTest.size) + } + + @Test + fun authClosingCodeSetsTerminalAuthExpiredAndDoesNotAutoRedial() = + runTest { + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + every { socket.send(any()) } returns true + + fixture.transport.connectedForTest.set(true) + fixture.transport.intentionalCloseForTest.set(false) + fixture.transport.connectionGenerationForTest.set(1) + fixture.transport.webSocketForTest = socket + + // Register a pending RPC call to verify it is NOT newly rejected + val pendingDeferred = fixture.rpcChannel.request("custom.in_flight", emptyMap()) + assertFalse(pendingDeferred.isCompleted) + + val listener = fixture.transport.createListenerForTest(1) + listener.onClosing(socket, 4401, "unauthorized") + + assertEquals(ConnectionStatus.AUTH_EXPIRED, fixture.transport.connectionStatus.value) + assertTrue(fixture.transport.isConnected) + assertNull(fixture.transport.reconnectJobForTest) + assertFalse(pendingDeferred.isCompleted) + + listener.onClosed(socket, 4401, "unauthorized") + + assertFalse(fixture.transport.isConnected) + assertEquals(ConnectionStatus.AUTH_EXPIRED, fixture.transport.connectionStatus.value) + assertNull(fixture.transport.reconnectJobForTest) + assertFalse(pendingDeferred.isCompleted) + + pendingDeferred.cancel() + } + + @Test + fun disconnectPreservesPendingCallsWhileClearFlagControlsQueueAndLeaseCleanup() = + runTest { + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + every { socket.send(any()) } returns true + + fixture.transport.connectedForTest.set(true) + fixture.transport.intentionalCloseForTest.set(false) + fixture.transport.webSocketForTest = socket + + // Register actual pending RPC call over socket accepting send + val pendingDeferred = fixture.rpcChannel.request("custom.in_flight", emptyMap()) + assertFalse(pendingDeferred.isCompleted) + + // Populate retryable message queue + fixture.transport.messageQueueForTest.add("{\"jsonrpc\":\"2.0\",\"id\":\"call-1\"}") + + // Ordinary disconnect preserves queued messages and does NOT reject pending calls + fixture.transport.disconnect(clearPendingMessages = false) + assertEquals(1, fixture.transport.messageQueueForTest.size) + assertFalse(fixture.transport.isConnected) + assertFalse(pendingDeferred.isCompleted) + + // Disconnect with clearPendingMessages = true clears queue and resets leases, still not rejecting pending calls + fixture.transport.acquireBackgroundConnectionLease() + assertTrue(fixture.transport.hasBackgroundConnectionLease()) + + fixture.transport.disconnect(clearPendingMessages = true) + assertTrue(fixture.transport.messageQueueForTest.isEmpty()) + assertFalse(fixture.transport.hasBackgroundConnectionLease()) + assertFalse(pendingDeferred.isCompleted) + + pendingDeferred.cancel() + } + + @Test + fun reconnectForNetworkChangeRejectsPendingCallsAndExecutesReplacementCallbackOnce() = + runTest { + every { AuthManager.isAutoReconnect() } returns true + + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + every { socket.send(any()) } returns true + + fixture.transport.connectedForTest.set(true) + fixture.transport.intentionalCloseForTest.set(false) + fixture.transport.connectionGenerationForTest.set(1) + fixture.transport.webSocketForTest = socket + + // Register non-empty pending RPC while socket accepts send + val pendingDeferred = fixture.rpcChannel.request("custom.network_call", emptyMap()) + assertFalse(pendingDeferred.isCompleted) + + var replacementOpens = 0 + fixture.transport.reconnectForNetworkChange( + networkAvailable = true, + openReplacement = { replacementOpens++ }, + ) + + // Outgoing socket canceled once + verify(exactly = 1) { socket.cancel() } + assertEquals(1, replacementOpens) + assertEquals(2, fixture.transport.connectionGenerationForTest.get()) + assertFalse(fixture.transport.isConnected) + assertEquals(ConnectionStatus.RECONNECTING, fixture.transport.connectionStatus.value) + assertNull(fixture.transport.webSocketForTest) + + // Network replacement uses the default connection-lost exception, not a timeout. + assertTrue(pendingDeferred.isCompleted) + try { + pendingDeferred.await() + fail("Expected HermesRpcException") + } catch (exception: HermesWsClient.HermesRpcException) { + assertEquals(0, exception.code) + assertEquals("Connection lost — request cancelled", exception.message) + } + } + + @Test + fun combinedLeasesSurviveIdleBackgroundUntilBothReleased() = + runTest { + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + + fixture.transport.connectedForTest.set(true) + fixture.transport.webSocketForTest = socket + fixture.transport.setConnectionStatusForTest(ConnectionStatus.CONNECTED) + + fixture.transport.acquireExternalActivityConnectionLease() + fixture.transport.acquireBackgroundConnectionLease() + assertTrue(fixture.transport.hasBackgroundConnectionLease()) + + // Backgrounding app with both leases active keeps connection alive + fixture.transport.setAppForeground(false) + assertTrue(fixture.transport.isConnected) + + // Releasing external lease keeps connection alive due to background lease + fixture.transport.releaseExternalActivityConnectionLease() + assertTrue(fixture.transport.isConnected) + + // Releasing background lease releases all leases and disconnects idle background socket + fixture.transport.releaseBackgroundConnectionLease() + assertFalse(fixture.transport.isConnected) + assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value) + } + + @Test + fun observeEventMarksPendingReplyStartAndCompleteRespectingIdlePolicy() = + runTest { + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + + fixture.transport.connectedForTest.set(true) + fixture.transport.webSocketForTest = socket + fixture.transport.setConnectionStatusForTest(ConnectionStatus.CONNECTED) + + // When a message start event arrives BEFORE setAppForeground(false) + val startEvent = WsEvent.MessageStart(sessionId = "s1") + fixture.transport.observeEvent(startEvent) + assertTrue(fixture.transport.pendingReply) + + // Setting app foreground to false while pending reply is true stays connected + fixture.transport.setAppForeground(false) + assertTrue(fixture.transport.isConnected) + + // Idle disconnect check while pending reply is true keeps socket connected + fixture.transport.disconnectIfIdleInBackground() + assertTrue(fixture.transport.isConnected) + + // Message complete event clears pending reply and immediately idle disconnects + val completeEvent = WsEvent.MessageComplete(text = "done", sessionId = "s1") + fixture.transport.observeEvent(completeEvent) + assertFalse(fixture.transport.pendingReply) + assertFalse(fixture.transport.isConnected) + assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value) + } + + @Test + fun sendDirectFrameBypassesQueueAndReturnsFalseWhenDisconnected() = + runTest { + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + every { socket.send("{\"jsonrpc\":\"2.0\",\"method\":\"connected_frame\"}") } returns true + every { socket.send("{\"jsonrpc\":\"2.0\",\"method\":\"send_fails\"}") } returns false + + fixture.transport.connectedForTest.set(true) + fixture.transport.webSocketForTest = socket + + // 1. Connected with socket accepting send -> returns true, no queue + val connectedResult = + fixture.transport.sendDirectFrame( + "{\"jsonrpc\":\"2.0\",\"method\":\"connected_frame\"}", + ) + assertTrue(connectedResult) + assertTrue(fixture.transport.messageQueueForTest.isEmpty()) + + // 2. Connected with socket rejecting send -> returns false, no queue + val sendFailsResult = fixture.transport.sendDirectFrame("{\"jsonrpc\":\"2.0\",\"method\":\"send_fails\"}") + assertFalse(sendFailsResult) + assertTrue(fixture.transport.messageQueueForTest.isEmpty()) + + // 3. Disconnected -> returns false, no queue + fixture.transport.connectedForTest.set(false) + fixture.transport.webSocketForTest = null + + val disconnectedResult = fixture.transport.sendDirectFrame("{\"jsonrpc\":\"2.0\",\"method\":\"direct\"}") + assertFalse(disconnectedResult) + assertTrue(fixture.transport.messageQueueForTest.isEmpty()) + } +} From 4294d7edff8287e43b13739f9ec538d6a3c7b66c Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:09:50 +0300 Subject: [PATCH 106/107] feat: add source filter chips to history screen (#1425) --- .../ui/sessions/SessionsScreen.kt | 93 ++++++++++--------- .../ui/sessions/SessionsViewModel.kt | 29 +++++- .../ui/sessions/components/SessionCard.kt | 16 +++- .../ui/sessions/SessionSourceFilterTest.kt | 75 +++++++++++++++ 4 files changed, 165 insertions(+), 48 deletions(-) create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSourceFilterTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 804446cf..2fec303c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions @@ -33,22 +34,18 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.CallSplit import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SelectAll -import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog @@ -82,10 +79,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState 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.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -127,7 +124,9 @@ import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar import com.m57.hermescontrol.ui.sessions.components.SessionsDialogs import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow import com.m57.hermescontrol.ui.sessions.components.automationGroups +import com.m57.hermescontrol.ui.sessions.components.sourceLabel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first /** * Auto-load the next history page when the user scrolls to within this many @@ -136,30 +135,6 @@ import kotlinx.coroutines.delay private const val AUTO_LOAD_THRESHOLD = 6 private const val AGE_TICK_MS = 60_000L -/** - * Maps a session source string to a Material icon for visual identification. - */ -private fun sourceIcon(source: String?): ImageVector? = - when (source?.lowercase()) { - "telegram", "tg" -> Icons.AutoMirrored.Filled.Send - "web", "dashboard" -> Icons.Filled.Language - "api", "rest" -> Icons.Filled.Code - "cli", "terminal" -> Icons.Filled.Terminal - else -> null - } - -/** - * Maps a source string to a label for tooltip / accessibility. - */ -private fun sourceLabel(source: String?): String = - when (source?.lowercase()) { - "telegram", "tg" -> "Telegram" - "web", "dashboard" -> "Web" - "api", "rest" -> "API" - "cli", "terminal" -> "CLI" - else -> source ?: "Unknown" - } - /** * Builds an annotated string with search term highlighting. */ @@ -201,19 +176,22 @@ private fun highlightText( internal fun displayedSessions(state: SessionsUiState): List = if (state.isSearchMode) { - state.searchResults.map { result -> - val session = result.toSessionInfo() - SessionTreeItem( - session = session, - depth = 0, - branchStem = null, - displayTitle = - session.title?.takeIf(String::isNotBlank) - ?: session.display_name?.takeIf(String::isNotBlank) - ?: session.preview?.takeIf(String::isNotBlank)?.take(80) - ?: "Untitled", - ) - } + state.searchResults + .map { result -> result.toSessionInfo() } + .filter { session -> + state.activeSourceFilter?.let { session.source?.lowercase() == it } ?: true + }.map { session -> + SessionTreeItem( + session = session, + depth = 0, + branchStem = null, + displayTitle = + session.title?.takeIf(String::isNotBlank) + ?: session.display_name?.takeIf(String::isNotBlank) + ?: session.preview?.takeIf(String::isNotBlank)?.take(80) + ?: "Untitled", + ) + } } else { flattenSessionsWithBranches(state.displaySessions) } @@ -241,6 +219,7 @@ fun SessionsScreen( state.sessions, state.showHidden, state.searchResults, + state.sourceFilter, ) { displayedSessions(state) } @@ -547,6 +526,25 @@ fun SessionsScreen( ) } + // Source chips: hidden until a loaded row actually carries that source. + val sources = state.availableSources + if (sources.size > 1) { + LazyRow( + modifier = Modifier.fillMaxWidth().testTag("history_source_chips"), + contentPadding = PaddingValues(horizontal = spacing.md), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + items(sources, key = { it }) { source -> + FilterChip( + selected = state.activeSourceFilter == source, + onClick = { viewModel.selectSourceFilter(source) }, + label = { Text(sourceLabel(source)) }, + modifier = Modifier.testTag("history_source_chip_$source"), + ) + } + } + } + // ── Search + bulk toggle (always visible) ───────────── Row( modifier = @@ -806,6 +804,17 @@ fun SessionsScreen( // ── Session list ──────────────────────────────────── val listState = rememberLazyListState() + // Opening (or switching section) always starts at the top. Keyed items otherwise + // keep the old anchor row when fresher sessions land above it after the refresh. + var userScrolled by remember(state.section) { mutableStateOf(false) } + LaunchedEffect(state.section) { + snapshotFlow { listState.isScrollInProgress }.first { it } + userScrolled = true + } + val firstSessionId = sessionsToDisplay.firstOrNull()?.session?.id + LaunchedEffect(state.section, firstSessionId, userScrolled) { + if (!userScrolled && firstSessionId != null) listState.scrollToItem(0) + } // Fluid infinite scroll: once the user reaches within // AUTO_LOAD_THRESHOLD items of the end, pull the next // page automatically. Driven by real scroll position diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 65cae410..421f9f40 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -110,6 +110,7 @@ data class SessionsUiState( val searchNextOffset: Int? = null, val searchLoadMoreError: String? = null, val showHidden: Boolean = false, + val sourceFilter: String? = null, val pinnedExpanded: Boolean = true, val liveStatuses: Map = emptyMap(), // Named projects used to label each row with the workspace it belongs to. @@ -120,9 +121,30 @@ data class SessionsUiState( val hasHiddenSessions: Boolean get() = sessions.any { it.hidden == true } - val displaySessions: List + // Source chips only exist for sources present in the rows currently loaded. + val availableSources: List + get() { + val rows = if (isSearchMode) searchResults.map { it.source } else visibleBySource.map { it.source } + return rows + .mapNotNull { it?.lowercase()?.takeIf(String::isNotBlank) } + .groupingBy { it } + .eachCount() + .entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .map { it.key } + } + + val activeSourceFilter: String? + get() = sourceFilter?.takeIf { it in availableSources } + + private val visibleBySource: List get() = if (showHidden) sessions else sessions.filter { it.hidden != true } + val displaySessions: List + get() = + activeSourceFilter?.let { f -> visibleBySource.filter { it.source?.lowercase() == f } } + ?: visibleBySource + val pinnedSessions: List get() = displaySessions.filter { it.pinned == true } } @@ -339,6 +361,7 @@ class SessionsViewModel( _uiState.update { it.copy( section = section, + sourceFilter = null, isLoading = false, isLoadingMore = false, sessions = emptyList(), @@ -701,6 +724,10 @@ class SessionsViewModel( // ── Bulk selection ─────────────────────────────────────────────────── + fun selectSourceFilter(source: String?) { + _uiState.update { it.copy(sourceFilter = if (it.sourceFilter == source) null else source) } + } + fun toggleShowHidden() { _uiState.update { it.copy(showHidden = !it.showHidden) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt index fb1b34b4..cd31a0d9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt @@ -98,19 +98,25 @@ fun BranchRow( fun sourceIcon(source: String?): ImageVector? = when (source?.lowercase()) { "telegram", "tg" -> Icons.AutoMirrored.Filled.Send - "web", "dashboard" -> Icons.Filled.Language - "api", "rest" -> Icons.Filled.Code + "web", "dashboard", "webui" -> Icons.Filled.Language + "api", "rest", "api_server" -> Icons.Filled.Code "cli", "terminal" -> Icons.Filled.Terminal else -> null } fun sourceLabel(source: String?): String = when (source?.lowercase()) { + null, "" -> "Unknown" "telegram", "tg" -> "Telegram" - "web", "dashboard" -> "Web" - "api", "rest" -> "API" + "web", "dashboard", "webui" -> "Web" + "api", "rest", "api_server" -> "API" "cli", "terminal" -> "CLI" - else -> source ?: "Unknown" + "tui" -> "TUI" + "desktop" -> "Desktop" + "acp" -> "ACP" + "cron" -> "Cron" + "bot_room" -> "Bot room" + else -> source.replace('_', ' ').replaceFirstChar { it.uppercase() } } fun highlightText( diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSourceFilterTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSourceFilterTest.kt new file mode 100644 index 00000000..333a3c36 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSourceFilterTest.kt @@ -0,0 +1,75 @@ +package com.m57.hermescontrol.ui.sessions + +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionSearchResult +import com.m57.hermescontrol.ui.sessions.components.sourceLabel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SessionSourceFilterTest { + private val rows = + listOf( + SessionInfo("a", source = "cli"), + SessionInfo("b", source = "CLI"), + SessionInfo("c", source = "desktop"), + SessionInfo("d", source = "api_server"), + SessionInfo("e", source = "desktop", hidden = true), + SessionInfo("f", source = null), + ) + + @Test + fun availableSources_onlyLoadedVisibleSources_orderedByCountThenName() { + val state = SessionsUiState(sessions = rows) + assertEquals(listOf("cli", "api_server", "desktop"), state.availableSources) + } + + @Test + fun availableSources_includesHiddenOnlyWhenShown() { + val onlyHidden = listOf(SessionInfo("x", source = "tui", hidden = true), SessionInfo("y", source = "cli")) + assertEquals(listOf("cli"), SessionsUiState(sessions = onlyHidden).availableSources) + assertEquals( + listOf("cli", "tui"), + SessionsUiState(sessions = onlyHidden, showHidden = true).availableSources, + ) + } + + @Test + fun filter_isCaseInsensitiveAndNarrowsList() { + val state = SessionsUiState(sessions = rows, sourceFilter = "cli") + assertEquals(listOf("a", "b"), state.displaySessions.map { it.id }) + } + + @Test + fun staleFilter_fallsBackToAll() { + val state = SessionsUiState(sessions = rows, sourceFilter = "telegram") + assertNull(state.activeSourceFilter) + assertEquals(5, state.displaySessions.size) + } + + @Test + fun searchMode_usesSearchResultSources() { + val state = + SessionsUiState( + searchQuery = "q", + sessions = rows, + searchResults = + listOf( + SessionSearchResult("s1", source = "telegram"), + SessionSearchResult("s2", source = "cli"), + ), + sourceFilter = "telegram", + ) + assertEquals(listOf("cli", "telegram"), state.availableSources) + assertEquals(listOf("s1"), displayedSessions(state).map { it.session.id }) + } + + @Test + fun sourceLabel_mapsKnownAndTitleCasesUnknown() { + assertEquals("API", sourceLabel("api_server")) + assertEquals("Desktop", sourceLabel("desktop")) + assertEquals("TUI", sourceLabel("tui")) + assertEquals("Whatsapp cloud", sourceLabel("whatsapp_cloud")) + assertEquals("Unknown", sourceLabel(null)) + } +} From 63105b78439659d7c88f565727b1bd9e2361426f Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:52:39 +0300 Subject: [PATCH 107/107] fix(#1427): recover sends past unresolved receipts (#1428) --- .../components/PendingSendRecoveryTest.kt | 103 ++++++++++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 13 +- .../hermescontrol/ui/chat/ChatSendStore.kt | 15 ++ .../hermescontrol/ui/chat/ChatViewModel.kt | 49 +++-- .../ui/chat/components/PendingSendRecovery.kt | 179 ++++++++++++++++++ .../ui/chat/fullbleed/TranscriptUiState.kt | 4 +- app/src/main/res/values/strings.xml | 6 + .../ui/chat/ChatSendStoreTest.kt | 24 +++ .../ui/chat/ChatViewModelTest.kt | 126 +++++++++++- 9 files changed, 491 insertions(+), 28 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendRecoveryTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendRecovery.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendRecoveryTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendRecoveryTest.kt new file mode 100644 index 00000000..f80bd488 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendRecoveryTest.kt @@ -0,0 +1,103 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +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 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.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PendingSendRecoveryTest { + @get:Rule + val compose = createComposeRule() + + private val uncertain = + PendingSend( + id = "uncertain", + scope = "test", + sessionId = "session", + text = "Possibly delivered", + mode = BusySendMode.QUEUE, + state = PendingSendState.UNKNOWN, + ) + + @Test + fun uncertainRetryRequiresExplicitConfirmation() { + var retried: String? = null + compose.setContent { + MaterialTheme { + PendingSendRecovery(listOf(uncertain), true, false, { retried = it }, {}) + } + } + compose.onNodeWithTag("pending_send_recovery").assertIsDisplayed().performClick() + compose.onNodeWithText("Possibly delivered").assertIsDisplayed() + compose.onNodeWithTag("pending_retry_uncertain").performClick() + compose.runOnIdle { assertNull(retried) } + compose.onNodeWithText("Cancel").assertIsDisplayed().performClick() + compose.runOnIdle { assertNull(retried) } + compose.onNodeWithTag("pending_retry_uncertain").performClick() + compose.onNodeWithTag("pending_retry_confirm").performClick() + compose.runOnIdle { assertEquals("uncertain", retried) } + } + + @Test + fun disconnectedSessionCanInspectAndDismissButCannotRetry() { + var dismissed: String? = null + compose.setContent { + MaterialTheme { + PendingSendRecovery(listOf(uncertain), false, false, {}, { dismissed = it }) + } + } + compose.onNodeWithTag("pending_send_recovery").performClick() + compose.onNodeWithTag("pending_retry_uncertain").assertIsNotEnabled() + compose.onNodeWithTag("pending_discard_uncertain").performClick() + compose.runOnIdle { assertNull(dismissed) } + compose + .onNodeWithText( + "Remove this message from the device queue? This does not cancel work or delete messages already received by the server.", + ).assertIsDisplayed() + compose.onNodeWithTag("pending_discard_confirm").performClick() + compose.runOnIdle { assertEquals("uncertain", dismissed) } + } + + @Test + fun emptyOutboxDoesNotAddAComposerControl() { + compose.setContent { + MaterialTheme { + PendingSendRecovery(emptyList(), true, false, {}, {}) + } + } + compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist() + } + + @Test + fun longUncertainMessageKeepsConfirmationActionsVisible() { + compose.setContent { + MaterialTheme { + PendingSendRecovery( + listOf(uncertain.copy(text = "Long diagnostic text ".repeat(200))), + true, + false, + {}, + {}, + ) + } + } + compose.onNodeWithTag("pending_send_recovery").performClick() + compose.onNodeWithTag("pending_retry_uncertain").performScrollTo().performClick() + compose.onNodeWithTag("pending_retry_confirm").assertIsDisplayed() + compose.onNodeWithText("Cancel").assertIsDisplayed() + } +} 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 b0b53a94..f45c4fd9 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 @@ -107,6 +107,7 @@ import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.MediaViewerDialog +import com.m57.hermescontrol.ui.chat.components.PendingSendRecovery import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay import com.m57.hermescontrol.ui.chat.components.ReloginDialog import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard @@ -180,7 +181,7 @@ 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 displayedMessages = transcriptState.messages val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current @@ -851,6 +852,16 @@ fun ChatScreen( }, ) + if (!timelineState.isHistorical) { + PendingSendRecovery( + sends = state.pendingSends, + canSend = state.isConnected && state.isSessionReady, + mainTurnBusy = state.isMainTurnBusy, + onSendAgain = viewModel::sendQueuedNow, + onDiscard = viewModel::discardPendingSend, + ) + } + ChatInputBar( inputFieldValue = inputFieldValue, onInputChange = { inputFieldValue = it }, 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 index 4b659f0e..d060fc64 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt @@ -149,3 +149,18 @@ class ChatSendStore( rows = next } } + +/** #1427: uncertain receipt bubbles live in recovery UI, not after their server transcript counterpart. */ +internal fun messagesWithoutUnconfirmedReceipts( + messages: List, + pending: List, +): List { + val recoveryIds = + pending + .filter { + it.state == PendingSendState.ACCEPTED || it.state == PendingSendState.UNKNOWN || + it.state == PendingSendState.REJECTED + }.mapTo(mutableSetOf()) { it.id } + if (recoveryIds.isEmpty()) return messages + return messages.filterNot { it.id in recoveryIds && it.canonicalRestId == 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 1cb6e707..977283ad 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 @@ -69,6 +69,7 @@ import com.m57.hermescontrol.ui.common.ActionProgressController import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow @@ -81,6 +82,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -2630,6 +2632,8 @@ class ChatViewModel( ) { sendStore.update(messageId) { it.copy(state = state) } publishPendingSends() + // #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends. + if (state != PendingSendState.SENDING) drainPendingQueue() } private fun removePendingSend(messageId: String) { @@ -2672,7 +2676,7 @@ class ChatViewModel( _uiState.update { it.copy(errorMessage = reason, isAgentTyping = mainTurnBusy) } } - /** Send one locally queued turn only after the previous receipt has reached REST history. */ + /** #1427: serialize submissions, not durable-history reconciliation; never replay uncertain rows. */ @Synchronized private fun drainPendingQueue() { val sessionId = _uiState.value.currentSessionId ?: return @@ -2686,11 +2690,7 @@ class ChatViewModel( 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 - } - ) { + if (rows.any { it.state == PendingSendState.SENDING }) { return } val next = rows.firstOrNull { it.state == PendingSendState.QUEUED } ?: return @@ -2736,6 +2736,13 @@ class ChatViewModel( "Could not retain queued attachment" }, ) + } finally { + if (queueDrainJob === currentCoroutineContext()[Job]) { + queueDrainJob = null + // Preparation can restore the same known-unsent row. Leave it for explicit recovery. + val restored = sendStore.all().any { it.id == next.id && it.state == PendingSendState.QUEUED } + if (currentCoroutineContext().isActive && !restored) drainPendingQueue() + } } } } @@ -2794,12 +2801,28 @@ class ChatViewModel( } } + /** #1427: explicit recovery can dismiss an uncertain receipt without guessing delivery or resending it. */ + @Synchronized + fun discardPendingSend(id: String) { + val row = sendStore.all().firstOrNull { it.id == id } ?: return + if (row.scope != sendScope() || row.sessionId != _uiState.value.currentSessionId || + row.state == PendingSendState.SENDING || + synchronized(pendingSendReservationLock) { pendingSendReservations.any { it.pending.id == id } } + ) { + return + } + removeUnconfirmedBubble(id) + removePendingSend(id) + 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 + runtimeSessionId == null || !_uiState.value.isSessionReady || + wsClient.connectionStatus.value != ConnectionStatus.CONNECTED ) { return } @@ -2822,7 +2845,7 @@ class ChatViewModel( 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) + it.state == PendingSendState.SENDING } if (others) { _uiState.update { it.copy(errorMessage = "Wait for the current send to settle") } @@ -2942,12 +2965,7 @@ class ChatViewModel( val hasOutstandingDelivery = sendStore.all().any { it.scope == sendScope() && it.sessionId == _uiState.value.currentSessionId && - it.state in - setOf( - PendingSendState.SENDING, - PendingSendState.ACCEPTED, - PendingSendState.UNKNOWN, - ) + it.state == PendingSendState.SENDING } || hasPendingSendReservation(sendScope(), _uiState.value.currentSessionId) val busy = wasStreaming || hasOutstandingDelivery val selectedMode = modeOverride ?: _uiState.value.busySendMode @@ -3825,8 +3843,7 @@ class ChatViewModel( val busy = mainTurnBusy || sendStore.all().any { - it.scope == sendScope() && it.sessionId == storageId && - it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + it.scope == sendScope() && it.sessionId == storageId && it.state == PendingSendState.SENDING } try { sendStore.put( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendRecovery.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendRecovery.kt new file mode 100644 index 00000000..7d87e5c3 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendRecovery.kt @@ -0,0 +1,179 @@ +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.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +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 + +/** #1427: a compact recovery entry keeps uncertain delivery visible without a floating queue panel. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PendingSendRecovery( + sends: List, + canSend: Boolean, + mainTurnBusy: Boolean, + onSendAgain: (String) -> Unit, + onDiscard: (String) -> Unit, +) { + if (sends.isEmpty()) return + var open by remember { mutableStateOf(false) } + var retryId by remember { mutableStateOf(null) } + var discardId by remember { mutableStateOf(null) } + val keyboard = LocalSoftwareKeyboardController.current + val submissionInFlight = sends.any { it.state == PendingSendState.SENDING } + TextButton( + onClick = { + keyboard?.hide() + open = true + }, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("pending_send_recovery"), + ) { + Text(pluralStringResource(R.plurals.chat_pending_count, sends.size, sends.size)) + } + if (open) { + ModalBottomSheet(onDismissRequest = { open = false }) { + Text( + stringResource(R.string.chat_pending_sends), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) { + items(sends, key = { it.id }) { send -> + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(send.text, style = MaterialTheme.typography.bodyMedium) + Text( + stringResource( + when (send.state) { + PendingSendState.QUEUED -> R.string.chat_pending_queued + PendingSendState.PARKED -> R.string.chat_pending_parked + PendingSendState.SENDING -> R.string.chat_pending_sending + PendingSendState.ACCEPTED -> R.string.chat_pending_accepted + PendingSendState.UNKNOWN -> R.string.chat_pending_unknown + PendingSendState.REJECTED -> R.string.chat_pending_rejected + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (send.attachments.isNotEmpty()) { + Text(stringResource(R.string.chat_pending_attachments)) + } + Row { + TextButton( + onClick = { + if (mainTurnBusy || send.state == PendingSendState.ACCEPTED || + send.state == PendingSendState.UNKNOWN + ) { + retryId = send.id + } else { + onSendAgain(send.id) + } + }, + enabled = canSend && !submissionInFlight && !send.requiresAttachmentRecovery, + modifier = Modifier.heightIn(min = 48.dp).testTag("pending_retry_${send.id}"), + ) { + Text( + stringResource( + if (send.state == PendingSendState.ACCEPTED || + send.state == PendingSendState.UNKNOWN + ) { + R.string.chat_pending_send_again + } else { + R.string.chat_pending_send_now + }, + ), + ) + } + TextButton( + onClick = { discardId = send.id }, + enabled = send.state != PendingSendState.SENDING, + modifier = Modifier.heightIn(min = 48.dp).testTag("pending_discard_${send.id}"), + ) { + Text(stringResource(R.string.chat_pending_discard)) + } + } + } + } + } + } + } + sends.firstOrNull { it.id == retryId }?.let { send -> + AlertDialog( + onDismissRequest = { retryId = null }, + title = { Text(stringResource(R.string.chat_pending_send_again)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(send.text, maxLines = 6, overflow = TextOverflow.Ellipsis) + if (send.state == PendingSendState.ACCEPTED || send.state == PendingSendState.UNKNOWN) { + Text(stringResource(R.string.chat_pending_unknown)) + } + if (mainTurnBusy) Text(stringResource(R.string.chat_pending_send_now_warning)) + } + }, + confirmButton = { + TextButton(onClick = { + retryId = null + onSendAgain(send.id) + }, enabled = canSend && !submissionInFlight, modifier = Modifier.testTag("pending_retry_confirm")) { + Text(stringResource(R.string.chat_pending_send_again)) + } + }, + dismissButton = { + TextButton(onClick = { retryId = null }) { Text(stringResource(R.string.system_confirm_cancel)) } + }, + ) + } + sends.firstOrNull { it.id == discardId }?.let { send -> + AlertDialog( + onDismissRequest = { discardId = null }, + title = { Text(stringResource(R.string.chat_pending_discard)) }, + text = { Text(stringResource(R.string.chat_pending_discard_warning)) }, + confirmButton = { + TextButton( + onClick = { + discardId = null + onDiscard(send.id) + }, + enabled = send.state != PendingSendState.SENDING, + modifier = + Modifier.testTag( + "pending_discard_confirm", + ), + ) { + Text(stringResource(R.string.chat_pending_discard)) + } + }, + dismissButton = { + TextButton(onClick = { discardId = null }) { Text(stringResource(R.string.system_confirm_cancel)) } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt index f02686fa..40baf6f3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt @@ -10,6 +10,7 @@ import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.VaultCodePromptUi import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi +import com.m57.hermescontrol.ui.chat.messagesWithoutUnconfirmedReceipts /** The transcript's resolved, read-only state; the list never receives the whole chat ViewModel. */ data class TranscriptUiState( @@ -48,7 +49,8 @@ data class TranscriptUiState( ): TranscriptUiState { val historical = timeline.isHistorical return TranscriptUiState( - messages = timeline.historyMessages ?: chat.messages, + messages = + timeline.historyMessages ?: messagesWithoutUnconfirmedReceipts(chat.messages, chat.pendingSends), streamingState = if (historical) StreamingState() else streaming, isAgentTyping = !historical && chat.isAgentTyping, typingEffectEnabled = !historical && chat.typingEffectEnabled, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 213cfc57..9c972ab0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -219,6 +219,12 @@ Send mode for this message This choice applies once; the saved default stays the same. Pending messages + + %d pending message + %d pending messages + + Dismiss + Remove this message from the device queue? This does not cancel work or delete messages already received by the server. Queued on this device Parked after Stop Sending\u2026 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 index 89d3e122..006837e0 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt @@ -204,4 +204,28 @@ class ChatSendStoreTest { assertEquals(PendingSendState.UNKNOWN, restored.state) assertEquals(0, restored.attempts) } + + @Test + fun uncertainReceiptGhostsAreHiddenWithoutConfirmingChangedServerIdentity() { + val local = ChatMessage("send-1", MessageRole.USER, "repeat", serverRowId = 10) + val server = ChatMessage("rest-session-20", MessageRole.USER, "repeat", serverRowId = 20) + val reply = ChatMessage("rest-session-21", MessageRole.ASSISTANT, "done", serverRowId = 21) + val receipt = pending("send-1", "repeat").copy(userRowId = 10) + val merged = mergeTranscriptWithLive(listOf(server, reply), listOf(local), preserveLiveIds = true) + + assertEquals( + listOf(server.id, reply.id), + messagesWithoutUnconfirmedReceipts(merged, listOf(receipt)).map { it.id }, + ) + assertTrue(pendingSendIdsConfirmedByDurableAliases(merged, listOf(receipt)).isEmpty()) + } + + @Test + fun queuedBubblesAndConfirmedAliasesStayInTheTranscript() { + val queued = ChatMessage("queued", MessageRole.USER, "later") + val confirmed = ChatMessage("confirmed", MessageRole.USER, "delivered", restId = "rest-session-10") + val pending = listOf(pending("queued", "later", PendingSendState.QUEUED), pending("confirmed", "delivered")) + + assertEquals(listOf(queued, confirmed), messagesWithoutUnconfirmedReceipts(listOf(queued, confirmed), pending)) + } } 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 65c97925..19705146 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 @@ -3844,7 +3844,7 @@ class ChatViewModelTest { } @Test - fun interruptWithUnknownDeliveryQueuesWithoutInterrupting() = + fun interruptWithUnknownDeliverySubmitsWithoutReplayingEarlierPrompt() = runTest { val (viewModel, sessionId) = createViewModelWithSession() every { HermesWsClient.sendMessage(sessionId, "Uncertain", any(), false) } answers { @@ -3863,8 +3863,8 @@ class ChatViewModelTest { val replacement = viewModel.uiState.value.pendingSends .single { it.text == "Replacement" } - assertEquals(BusySendMode.QUEUE, replacement.mode) - assertEquals(PendingSendState.QUEUED, replacement.state) + assertEquals(BusySendMode.CORRECT, replacement.mode) + assertEquals(PendingSendState.SENDING, replacement.state) assertEquals( PendingSendState.UNKNOWN, viewModel.uiState.value.pendingSends @@ -3872,11 +3872,12 @@ class ChatViewModelTest { .state, ) verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } - verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + verify(exactly = 1) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Uncertain", any(), any()) } } @Test - fun interruptWithAcceptedDeliveryQueuesWithoutInterrupting() = + fun interruptWithAcceptedDeliverySubmitsWithoutReplayingEarlierPrompt() = runTest { val (viewModel, sessionId) = createViewModelWithSession() every { HermesWsClient.sendMessage(sessionId, "Accepted", any(), false) } answers { @@ -3895,8 +3896,8 @@ class ChatViewModelTest { val replacement = viewModel.uiState.value.pendingSends .single { it.text == "Replacement" } - assertEquals(BusySendMode.QUEUE, replacement.mode) - assertEquals(PendingSendState.QUEUED, replacement.state) + assertEquals(BusySendMode.CORRECT, replacement.mode) + assertEquals(PendingSendState.SENDING, replacement.state) assertEquals( PendingSendState.ACCEPTED, viewModel.uiState.value.pendingSends @@ -3904,7 +3905,8 @@ class ChatViewModelTest { .state, ) verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } - verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + verify(exactly = 1) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Accepted", any(), any()) } } @Test @@ -4149,7 +4151,7 @@ class ChatViewModelTest { } @Test - fun sendNowWaitsForEarlierAcceptedTurnToReachRestHistory() = + fun acceptedReceiptReleasesNextQueuedSubmissionWithoutWaitingForHistory() = runTest { val (viewModel, sessionId) = createViewModelWithSession() every { HermesWsClient.sendMessage(sessionId, "First", any(), true) } answers { @@ -4174,12 +4176,13 @@ class ChatViewModelTest { advanceUntilIdle() assertEquals( - PendingSendState.QUEUED, + PendingSendState.SENDING, viewModel.uiState.value.pendingSends .single { it.id == second.id } .state, ) verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Second", any(), true) } } @Test @@ -10536,4 +10539,107 @@ class ChatViewModelTest { advanceUntilIdle() assertTrue(vm.uiState.value.canInterrupt) } + + @Test + fun changedReceiptIdentityDoesNotBlockNewPromptsOrFalselyConfirmDelivery() = + runTest { + val store = ChatSendStore() + val (vm, session) = createViewModelWithSession(sendStore = store) + every { HermesWsClient.sendMessage(session, "probe-first", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke("probe-submit") + "probe-submit" + } + assertTrue(vm.sendMessage("probe-first")) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult("probe-submit", mapOf("status" to "streaming", "user_row_id" to 10))) + advanceUntilIdle() + coEvery { ApiClient.hermesApi.getSessionMessages(session, any(), any(), any(), any()) } returns + Response.success( + SessionMessagesResponse( + messages = + listOf( + SessionMessage(id = 20, role = "user", content = JsonPrimitive("probe-first")), + SessionMessage(id = 21, role = "assistant", content = JsonPrimitive("probe-reply")), + ), + pagination = PaginationInfo(order = "latest"), + ), + ) + mockEventsFlow.emit(WsEvent.MessageStart(session)) + mockEventsFlow.emit(WsEvent.MessageComplete("probe-reply", session)) + advanceUntilIdle() + vm.syncCurrentSession() + advanceUntilIdle() + assertEquals( + 2, + vm.uiState.value.messages + .count { it.role == MessageRole.USER && it.content == "probe-first" }, + ) + assertEquals( + "probe-first", + vm.uiState.value.messages + .last() + .content, + ) + assertEquals(PendingSendState.ACCEPTED, store.all().single().state) + assertTrue(vm.uiState.value.isSessionReady) + assertTrue(vm.sendMessage("probe-second")) + advanceUntilIdle() + assertTrue( + vm.uiState.value.messages + .any { it.content == "probe-second" }, + ) + assertEquals(PendingSendState.SENDING, store.all().single { it.text == "probe-second" }.state) + assertNull(vm.uiState.value.errorMessage) + verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-second", any(), any()) } + verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-first", any(), any()) } + assertEquals(PendingSendState.ACCEPTED, store.all().single { it.text == "probe-first" }.state) + assertEquals( + 1, + vm.transcriptState.value.messages + .count { it.content == "probe-first" }, + ) + } + + @Test + fun timeoutReleasesQueuedPromptWithoutReplayingUncertainSend() = + runTest { + val store = ChatSendStore() + val (vm, session) = createViewModelWithSession(sendStore = store) + every { HermesWsClient.sendMessage(session, "uncertain-first", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke("uncertain-submit") + "uncertain-submit" + } + assertTrue(vm.sendMessage("uncertain-first")) + advanceUntilIdle() + assertTrue(vm.sendMessage("queued-second")) + advanceUntilIdle() + assertEquals(PendingSendState.QUEUED, store.all().single { it.text == "queued-second" }.state) + vm.expireOutgoingRequest("uncertain-submit") + advanceUntilIdle() + + assertEquals(PendingSendState.UNKNOWN, store.all().single { it.text == "uncertain-first" }.state) + verify(exactly = 1) { HermesWsClient.sendMessage(session, "queued-second", any(), true) } + verify(exactly = 1) { HermesWsClient.sendMessage(session, "uncertain-first", any(), any()) } + } + + @Test + fun dismissUnknownReceiptDoesNotResendAndCannotDismissInFlight() = + runTest { + val store = ChatSendStore() + val (vm, session) = createViewModelWithSession(sendStore = store) + assertTrue(vm.sendMessage("dismiss-first")) + advanceUntilIdle() + val receipt = store.all().single() + vm.discardPendingSend(receipt.id) + assertEquals(PendingSendState.SENDING, store.all().single().state) + store.update(receipt.id) { it.copy(state = PendingSendState.UNKNOWN) } + vm.discardPendingSend(receipt.id) + advanceUntilIdle() + assertTrue(store.all().isEmpty()) + assertTrue( + vm.uiState.value.messages + .none { it.id == receipt.id }, + ) + verify(exactly = 1) { HermesWsClient.sendMessage(session, "dismiss-first", any(), any()) } + } }