release: promote dev to main for v1.30.0 (#1271)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Lx-204 <15377891490@163.com>
Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com>
Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-25 23:23:12 +03:00
1 parent 7386c3035b
commit e4004decb4
85 files changed
+9182 -363

No files matched your search

+1 -1
View File
@@ -75,7 +75,7 @@ jobs:
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: manual 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) - name: Build Codebase (Compile Only)
run: | run: |
+10
View File
@@ -36,6 +36,16 @@ android {
} }
signingConfigs { 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") { create("release") {
val isReleaseBuild = val isReleaseBuild =
gradle.startParameter.taskNames.any { name -> gradle.startParameter.taskNames.any { name ->
Binary file not shown.
@@ -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')"
]
}
}
@@ -41,7 +41,7 @@ class ChatMessagePagingDeviceTest {
Room Room
.databaseBuilder(context, HermesDatabase::class.java, databaseName) .databaseBuilder(context, HermesDatabase::class.java, databaseName)
.setDriver(driver()) .setDriver(driver())
.addMigrations(HermesDatabase.MIGRATION_8_9) .addMigrations(HermesDatabase.MIGRATION_8_9, HermesDatabase.MIGRATION_9_10)
.build() .build()
repository = ChatPersistenceRepository(database.chatMessageDao()) repository = ChatPersistenceRepository(database.chatMessageDao())
} }
@@ -234,6 +234,29 @@ class ChatMessagePagingDeviceTest {
assertPagingIndexUsed() assertPagingIndexUsed()
} }
@Test
fun migration9to10PreservesAmbiguousUnsentUuidUserAsUnknown() =
runBlocking {
val unsent =
ChatMessageEntity(
id = "pending-before-upgrade",
sessionId = "session",
role = "USER",
content = "may not have reached the server",
timestamp = 1L,
sortOrder = 1L,
)
createVersion9(listOf(unsent))
openDatabase()
val restored = database.chatMessageDao().getMessage(unsent.id)!!
assertEquals("UNKNOWN", restored.messageProvenance)
assertEquals(
com.m57.hermescontrol.ui.chat.MessageProvenance.UNKNOWN,
restored.toUiModel().messageProvenance,
)
assertEquals(unsent.content, restored.content)
}
@Test @Test
fun confirmedAliasPreservesLivePayloadAndCanonicalOrderAcrossReopen() = fun confirmedAliasPreservesLivePayloadAndCanonicalOrderAcrossReopen() =
runBlocking { runBlocking {
@@ -350,6 +373,63 @@ class ChatMessagePagingDeviceTest {
} }
} }
/** Build the exported v9 schema so Room validates the real non-destructive v9 -> v10 path. */
private fun createVersion9(rows: List<ChatMessageEntity>) {
database.close()
context.deleteDatabase(databaseName)
val schema =
InstrumentationRegistry
.getInstrumentation()
.context.assets
.open("com.m57.hermescontrol.data.local.HermesDatabase/9.json")
.bufferedReader()
.use { JSONObject(it.readText()).getJSONObject("database") }
val file = context.getDatabasePath(databaseName)
file.parentFile?.mkdirs()
driver().open(file.absolutePath).use { connection ->
val entity = schema.getJSONArray("entities").getJSONObject(0)
connection.execSQL(entity.getString("createSql").replace("\${TABLE_NAME}", "chat_messages"))
val indices = entity.getJSONArray("indices")
for (index in 0 until indices.length()) {
connection.execSQL(
indices.getJSONObject(index).getString("createSql").replace("\${TABLE_NAME}", "chat_messages"),
)
}
val setup = schema.getJSONArray("setupQueries")
for (index in 0 until setup.length()) connection.execSQL(setup.getString(index))
connection
.prepare(
"INSERT INTO chat_messages (id, session_id, role, content, reasoning_text, timestamp, " +
"tool_name, tool_call_id, tool_status, is_streaming, display_kind, token_count, tps, " +
"completion_id, rest_id, sort_group, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
).use { insert ->
rows.forEach { row ->
insert.bindText(1, row.id)
insert.bindText(2, row.sessionId)
insert.bindText(3, row.role)
insert.bindText(4, row.content)
insert.bindText(5, row.reasoningText)
insert.bindLong(6, row.timestamp)
row.toolName?.let { insert.bindText(7, it) } ?: insert.bindNull(7)
insert.bindText(8, row.toolCallId)
row.toolStatus?.let { insert.bindText(9, it) } ?: insert.bindNull(9)
insert.bindLong(10, if (row.isStreaming) 1L else 0L)
row.displayKind?.let { insert.bindText(11, it) } ?: insert.bindNull(11)
row.tokenCount?.let { insert.bindLong(12, it.toLong()) } ?: insert.bindNull(12)
row.tps?.let { insert.bindDouble(13, it) } ?: insert.bindNull(13)
row.completionId?.let { insert.bindText(14, it) } ?: insert.bindNull(14)
row.restId?.let { insert.bindText(15, it) } ?: insert.bindNull(15)
insert.bindLong(16, row.sortGroup.toLong())
insert.bindLong(17, row.sortOrder)
insert.step()
insert.reset()
insert.clearBindings()
}
}
connection.execSQL("PRAGMA user_version = 9")
}
}
private fun assertPagingIndexUsed() { private fun assertPagingIndexUsed() {
database.close() database.close()
driver().open(context.getDatabasePath(databaseName).absolutePath).use { connection -> driver().open(context.getDatabasePath(databaseName).absolutePath).use { connection ->
@@ -4,16 +4,19 @@ import android.content.pm.PackageManager
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.Attachment 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.data.ws.ConnectionStatus
import com.m57.hermescontrol.ui.common.ActionProgressController import com.m57.hermescontrol.ui.common.ActionProgressController
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -125,4 +128,37 @@ class ChatScreenTest {
composeTestRule.onNodeWithTag("chat_input").assertIsDisplayed() composeTestRule.onNodeWithTag("chat_input").assertIsDisplayed()
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
} }
@Test
fun chatScreen_primarySendUsesSavedBusyDefault() {
val uiState =
ChatUiState(
connectionStatus = ConnectionStatus.CONNECTED,
isSessionReady = true,
busySendMode = BusySendMode.QUEUE,
)
val mockViewModel = mockk<ChatViewModel>(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) }
}
} }
@@ -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()
}
}
@@ -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()
}
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat.components package com.m57.hermescontrol.ui.chat.components
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable 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.LocalLayoutDirection
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed 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.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.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick 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.Dp
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.test.espresso.Espresso.pressBack
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest 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 com.m57.hermescontrol.data.ws.CommandCatalog
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
@@ -42,7 +48,7 @@ import org.junit.runner.RunWith
@MediumTest @MediumTest
class ComposerInteractionTest { class ComposerInteractionTest {
@get:Rule @get:Rule
val composeTestRule = createComposeRule() val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private var micTaps = 0 private var micTaps = 0
private var sends = 0 private var sends = 0
@@ -52,6 +58,158 @@ class ComposerInteractionTest {
private var photosTaps = 0 private var photosTaps = 0
private var fileTaps = 0 private var fileTaps = 0
private fun setContextualComposer(
text: String,
isMainTurnBusy: Boolean,
attachments: List<Attachment> = 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<BusySendMode>()
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. */ /** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
private fun setComposer( private fun setComposer(
reasoningLevel: String? = "medium", reasoningLevel: String? = "medium",
@@ -246,7 +404,8 @@ class ComposerInteractionTest {
"short model pill should remain content-sized, width=$pillWidth toolbar=$toolbarWidth" "short model pill should remain content-sized, width=$pillWidth toolbar=$toolbarWidth"
} }
check(actionButton.right > toolbarBounds.right - 32.dp) { 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 @Test
fun attachmentTray_dismissesOnBackPress() { fun attachmentTray_dismissesOnActivityBack() {
setComposer() setComposer()
openAttachmentTray() openAttachmentTray()
pressBack() composeTestRule.runOnUiThread { composeTestRule.activity.onBackPressedDispatcher.onBackPressed() }
composeTestRule.mainClock.advanceTimeBy(300) composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist() composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
@@ -34,6 +34,7 @@ class ComposerReadinessTest {
onMicTap = {}, onMicTap = {},
isListening = false, isListening = false,
isAgentTyping = true, isAgentTyping = true,
canInterrupt = ready.value,
isConnected = true, isConnected = true,
commandCatalog = CommandCatalog(), commandCatalog = CommandCatalog(),
isSessionReady = ready.value, isSessionReady = ready.value,
@@ -42,13 +43,20 @@ class ComposerReadinessTest {
compose.onNodeWithTag("chat_input").performTextInput("held draft") compose.onNodeWithTag("chat_input").performTextInput("held draft")
compose.onNodeWithTag("chat_input").assertTextEquals("held draft") compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
compose.onNodeWithTag("chat_session_preparing").assertExists() 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.onNodeWithTag("send_button").assertIsNotEnabled().performClick()
compose.runOnIdle { assertEquals(0, sends) } compose.runOnIdle { assertEquals(0, sends) }
compose.runOnIdle { ready.value = true } compose.runOnIdle { ready.value = true }
compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist() 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.onNodeWithTag("send_button").assertIsEnabled().performClick()
compose.runOnIdle { assertEquals(1, sends) } compose.runOnIdle { assertEquals(1, sends) }
compose.runOnIdle { ready.value = false } compose.runOnIdle { ready.value = false }
compose.onNodeWithTag("stop_button").assertDoesNotExist()
compose.onNodeWithTag("send_button").assertIsNotEnabled() compose.onNodeWithTag("send_button").assertIsNotEnabled()
compose.onNodeWithTag("chat_input").assertTextEquals("held draft") compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
} }
@@ -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<ComponentActivity>()
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()
}
}
}
@@ -71,4 +71,28 @@ class ReasoningCardTest {
composeTestRule.onNodeWithTag("reasoning_card").performClick() composeTestRule.onNodeWithTag("reasoning_card").performClick()
composeTestRule.onNodeWithText("Show full").assertDoesNotExist() 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()
}
} }
@@ -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<ComponentActivity>()
@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()
}
}
@@ -1,14 +1,24 @@
package com.m57.hermescontrol.ui.settings.components 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.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest 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.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -20,6 +30,25 @@ class ChatSectionTest {
@get:Rule @get:Rule
val composeTestRule = createComposeRule() 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 @Test
fun messageStatsMasterOff_keepsChildrenVisibleCheckedAndDisabled() { fun messageStatsMasterOff_keepsChildrenVisibleCheckedAndDisabled() {
composeTestRule.setContent { composeTestRule.setContent {
@@ -48,6 +77,8 @@ class ChatSectionTest {
var assistantChanges = 0 var assistantChanges = 0
var tpsChanges = 0 var tpsChanges = 0
composeTestRule.setContent { composeTestRule.setContent {
// PR #1254: match the real settings page's scrollable viewport.
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
ChatSection( ChatSection(
typingEffectEnabled = false, typingEffectEnabled = false,
onTypingEffectEnabledChange = {}, onTypingEffectEnabledChange = {},
@@ -63,16 +94,30 @@ class ChatSectionTest {
onTokensPerSecondChange = { tpsChanges++ }, onTokensPerSecondChange = { tpsChanges++ },
) )
} }
}
composeTestRule.onNodeWithTag("settings_user_message_tokens").assertIsEnabled().performClick() composeTestRule
composeTestRule.onNodeWithTag("settings_assistant_message_tokens").assertIsEnabled().performClick() .onNodeWithTag("settings_user_message_tokens")
composeTestRule.onNodeWithTag("settings_tps").assertIsEnabled().performClick() .performScrollTo()
.assertIsEnabled()
.performClick()
composeTestRule
.onNodeWithTag(
"settings_assistant_message_tokens",
).performScrollTo()
.assertIsEnabled()
.performClick()
composeTestRule
.onNodeWithTag("settings_tps")
.performScrollTo()
.assertIsEnabled()
.performClick()
assertEquals(0, masterChanges) assertEquals(0, masterChanges)
assertEquals(1, userChanges) assertEquals(1, userChanges)
assertEquals(1, assistantChanges) assertEquals(1, assistantChanges)
assertEquals(1, tpsChanges) assertEquals(1, tpsChanges)
composeTestRule.onNodeWithTag("settings_message_stats").performClick() composeTestRule.onNodeWithTag("settings_message_stats").performScrollTo().performClick()
assertEquals(1, masterChanges) assertEquals(1, masterChanges)
assertEquals(1, userChanges) assertEquals(1, userChanges)
assertEquals(1, assistantChanges) assertEquals(1, assistantChanges)
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.config package com.m57.hermescontrol.data.config
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.theme.ThemePreset
@@ -22,6 +23,7 @@ data class ServerStoreState(
val pinnedModels: List<PinnedModel> = emptyList(), val pinnedModels: List<PinnedModel> = emptyList(),
val wsAuthParam: String = "token", val wsAuthParam: String = "token",
val typingEffectEnabled: Boolean = true, val typingEffectEnabled: Boolean = true,
val busySendMode: BusySendMode = BusySendMode.CORRECT,
val typingEffectDelayMs: Int = 30, val typingEffectDelayMs: Int = 30,
val chatFontScale: Float = 1.0f, val chatFontScale: Float = 1.0f,
val messageStatsEnabled: Boolean = false, val messageStatsEnabled: Boolean = false,
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.config.ServerUrlMigration
import com.m57.hermescontrol.data.config.resolvedBaseUrl import com.m57.hermescontrol.data.config.resolvedBaseUrl
import com.m57.hermescontrol.data.config.resolvedHost import com.m57.hermescontrol.data.config.resolvedHost
import com.m57.hermescontrol.data.config.resolvedPort 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.model.PinnedModel
import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.CookieManager import com.m57.hermescontrol.data.remote.CookieManager
@@ -805,6 +806,12 @@ object AuthManager {
fun isTypingEffectEnabled(): Boolean = serverStore.getLatestState().typingEffectEnabled 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) { fun setTypingEffectEnabled(enabled: Boolean) {
serverStore.update { it.copy(typingEffectEnabled = enabled) } serverStore.update { it.copy(typingEffectEnabled = enabled) }
} }
@@ -86,4 +86,7 @@ interface ChatMessageDao {
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId") @Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
suspend fun deleteMessagesForSession(sessionId: String) suspend fun deleteMessagesForSession(sessionId: String)
@Query("DELETE FROM chat_messages WHERE id = :id AND rest_id IS NULL")
suspend fun deleteUnconfirmedMessage(id: String)
} }
@@ -54,6 +54,8 @@ data class ChatMessageEntity(
val sortGroup: Int = 1, val sortGroup: Int = 1,
@ColumnInfo(name = "sort_order", defaultValue = "0") @ColumnInfo(name = "sort_order", defaultValue = "0")
val sortOrder: Long = 0, val sortOrder: Long = 0,
@ColumnInfo(name = "message_provenance", defaultValue = "'UNKNOWN'")
val messageProvenance: String = "UNKNOWN",
) )
internal fun ChatMessageEntity.isSessionStartMarker(): Boolean = internal fun ChatMessageEntity.isSessionStartMarker(): Boolean =
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.data.local package com.m57.hermescontrol.data.local
import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageProvenance
import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.ToolStatus import com.m57.hermescontrol.ui.chat.ToolStatus
@@ -40,6 +41,9 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
completionId = completionId, completionId = completionId,
restId = restId, restId = restId,
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 && !isSessionStartMarker() }, localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 && !isSessionStartMarker() },
messageProvenance =
MessageProvenance.entries.firstOrNull { it.name == messageProvenance }
?: MessageProvenance.UNKNOWN,
) )
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity = fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
@@ -59,4 +63,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
tps = tps, tps = tps,
completionId = completionId, completionId = completionId,
restId = restId, restId = restId,
messageProvenance = messageProvenance.name,
) )
@@ -14,7 +14,7 @@ import java.io.File
@Database( @Database(
entities = [ChatMessageEntity::class], entities = [ChatMessageEntity::class],
version = 9, version = 10,
exportSchema = true, exportSchema = true,
) )
abstract class HermesDatabase : RoomDatabase() { abstract class HermesDatabase : RoomDatabase() {
@@ -115,6 +115,17 @@ abstract class HermesDatabase : RoomDatabase() {
} }
} }
val MIGRATION_9_10: Migration =
object : Migration(9, 10) {
override suspend fun migrate(connection: SQLiteConnection) {
// v9 cannot prove whether a UUID-only row reached the server. Preserve that
// ambiguity; only new outgoing prompts record LOCAL_PENDING before submit.
connection.execSQL(
"ALTER TABLE chat_messages ADD COLUMN message_provenance TEXT NOT NULL DEFAULT 'UNKNOWN'",
)
}
}
suspend fun get(context: Context): HermesDatabase = suspend fun get(context: Context): HermesDatabase =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
instance?.let { return@withContext it } instance?.let { return@withContext it }
@@ -152,6 +163,7 @@ abstract class HermesDatabase : RoomDatabase() {
MIGRATION_6_7, MIGRATION_6_7,
MIGRATION_7_8, MIGRATION_7_8,
MIGRATION_8_9, MIGRATION_8_9,
MIGRATION_9_10,
).fallbackToDestructiveMigration(false) ).fallbackToDestructiveMigration(false)
.build() .build()
.also { instance = it } .also { instance = it }
@@ -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,
)
@@ -0,0 +1,11 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
enum class BusySendMode {
CORRECT,
QUEUE,
GUIDE,
INTERRUPT,
}
@@ -4,9 +4,11 @@ import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
/** /**
* Provides a Retrofit-backed [HermesApiService]. * Provides a Retrofit-backed [HermesApiService].
@@ -22,6 +24,9 @@ object ApiClient {
@Volatile @Volatile
private var service: HermesApiService? = null private var service: HermesApiService? = null
@Volatile
private var okHttpClient: OkHttpClient? = null
/** The current [HermesApiService] instance. Lazily created on first access. */ /** The current [HermesApiService] instance. Lazily created on first access. */
val hermesApi: HermesApiService val hermesApi: HermesApiService
get() { get() {
@@ -39,6 +44,7 @@ object ApiClient {
synchronized(this) { synchronized(this) {
retrofit = null retrofit = null
service = null service = null
okHttpClient = null
} }
} }
@@ -81,6 +87,64 @@ object ApiClient {
return tempRetrofit.create(HermesApiService::class.java) 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 ───────────────────────────────────────────────────────── // ── Internal ─────────────────────────────────────────────────────────
private fun buildService(): HermesApiService = private fun buildService(): HermesApiService =
@@ -111,25 +175,7 @@ object ApiClient {
// must NOT stamp one here — otherwise every REST tab (skills, cron, // must NOT stamp one here — otherwise every REST tab (skills, cron,
// config, ...) fails with "token expired" while the WS chat, which // config, ...) fails with "token expired" while the WS chat, which
// authenticates via ?ticket=, keeps working. // authenticates via ?ticket=, keeps working.
val authInterceptor = val authInterceptor = restAuthInterceptor()
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 okHttp = val okHttp =
OkHttpProvider OkHttpProvider
@@ -7,6 +7,8 @@ import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.AddMcpServerRequest import com.m57.hermescontrol.data.model.AddMcpServerRequest
import com.m57.hermescontrol.data.model.AgentPluginInstallBody import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.AnalyticsResponse 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.AuxiliaryModelsResponse
import com.m57.hermescontrol.data.model.BackupTriggerRequest import com.m57.hermescontrol.data.model.BackupTriggerRequest
import com.m57.hermescontrol.data.model.BulkDeleteRequest import com.m57.hermescontrol.data.model.BulkDeleteRequest
@@ -1030,4 +1032,14 @@ interface HermesApiService : KanbanApiService {
suspend fun deleteManagedFile( suspend fun deleteManagedFile(
@Body body: ManagedFileDelete, @Body body: ManagedFileDelete,
): Response<ManagedFileActionResponse> ): Response<ManagedFileActionResponse>
// ── 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<AudioTranscriptionResponse>
} }
@@ -26,6 +26,13 @@ object ProfileScopeInterceptor : Interceptor {
private val PROFILE_SCOPED_PREFIXES = private val PROFILE_SCOPED_PREFIXES =
listOf( listOf(
"/api/analytics", "/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/config",
"/api/credentials/pool", "/api/credentials/pool",
"/api/cron", "/api/cron",
@@ -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<String> {
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)
}
}
@@ -775,6 +775,7 @@ object HermesWsClient {
val method: String, val method: String,
val deferred: CompletableDeferred<Any?>, val deferred: CompletableDeferred<Any?>,
var timeoutJob: Job? = null, var timeoutJob: Job? = null,
val suppressErrorEvent: Boolean = false,
) )
/** Tracks in-flight [request] calls by their JSON-RPC id. */ /** Tracks in-flight [request] calls by their JSON-RPC id. */
@@ -794,11 +795,12 @@ object HermesWsClient {
method: String, method: String,
params: Map<String, Any> = emptyMap(), params: Map<String, Any> = emptyMap(),
timeoutMs: Long = REQUEST_TIMEOUT_MS, timeoutMs: Long = REQUEST_TIMEOUT_MS,
suppressErrorEvent: Boolean = false,
): CompletableDeferred<Any?> { ): CompletableDeferred<Any?> {
val deferred = CompletableDeferred<Any?>() val deferred = CompletableDeferred<Any?>()
val id = val id =
send(method, params) { reqId -> send(method, params) { reqId ->
pendingCalls[reqId] = PendingCall(method, deferred) pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent)
} }
deferred.invokeOnCompletion { cause -> deferred.invokeOnCompletion { cause ->
if (cause is CancellationException) { if (cause is CancellationException) {
@@ -1458,6 +1460,16 @@ object HermesWsClient {
disconnectIfIdleInBackground() 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) removeQueuedMessage(event.id)
resolvePending(event.id, null, event.error) resolvePending(event.id, null, event.error)
} }
@@ -37,6 +37,7 @@ object SubagentRepository {
.request( .request(
WsMethods.SUBAGENT_LIST, WsMethods.SUBAGENT_LIST,
mapOf("session_id" to sessionId), mapOf("session_id" to sessionId),
suppressErrorEvent = true,
).await() ).await()
decode<SubagentListResponse>(result) decode<SubagentListResponse>(result)
} catch (e: Exception) { } catch (e: Exception) {
@@ -65,7 +66,13 @@ object SubagentRepository {
if (sessionId.isNotBlank()) { if (sessionId.isNotBlank()) {
params["session_id"] = sessionId 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<SubagentTailResponse>(result) return decode<SubagentTailResponse>(result)
} }
@@ -11,6 +11,7 @@ object WsMethods {
const val SESSION_CREATE = "session.create" const val SESSION_CREATE = "session.create"
const val SESSION_INTERRUPT = "session.interrupt" const val SESSION_INTERRUPT = "session.interrupt"
const val SESSION_REDIRECT = "session.redirect" const val SESSION_REDIRECT = "session.redirect"
const val SESSION_STEER = "session.steer"
const val SESSION_DELETE = "session.delete" const val SESSION_DELETE = "session.delete"
const val SESSION_TITLE = "session.title" const val SESSION_TITLE = "session.title"
const val SESSION_BRANCH = "session.branch" const val SESSION_BRANCH = "session.branch"
@@ -17,6 +17,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.ui.chat.replyFailureFromPayload
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -27,6 +28,46 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean 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 * Foreground service that keeps the WebSocket connection alive while the app
* is backgrounded and posts a notification when a new assistant reply * is backgrounded and posts a notification when a new assistant reply
@@ -111,19 +152,22 @@ class ChatNotificationService : Service() {
if (!isAppInForeground.get()) { if (!isAppInForeground.get()) {
when (event) { when (event) {
is WsEvent.MessageComplete -> { is WsEvent.MessageComplete -> {
val preview =
event.text
.take(100)
.replace("\n", " ")
.ifBlank { getString(R.string.notif_new_message) }
val targetSessionId = val targetSessionId =
event.storedSessionId event.storedSessionId
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId) ?: 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( showReplyNotification(
text = preview, text = plan.text,
sessionId = targetSessionId, sessionId = plan.sessionId,
isReplyMessage = true, isReplyMessage = plan.isReplyMessage,
completionId = event.completionId, completionId = plan.completionId,
allowInlineReply = plan.allowInlineReply,
// The durable REST row for this turn, when the // The durable REST row for this turn, when the
// boundary armed before the prompt was submitted // boundary armed before the prompt was submitted
// still lets us name it unambiguously. Null is a // still lets us name it unambiguously. Null is a
@@ -132,7 +176,9 @@ class ChatNotificationService : Service() {
// is strictly better than dismissing the wrong // is strictly better than dismissing the wrong
// duplicate reply. // duplicate reply.
serverMessageId = serverMessageId =
coalesceTurnRow(targetSessionId, event.text), plan.correlationText?.let {
coalesceTurnRow(plan.sessionId, it)
},
) )
// The wait is over — retire the foreground // The wait is over — retire the foreground
// service. The reply notification above // service. The reply notification above
@@ -216,6 +262,7 @@ class ChatNotificationService : Service() {
isReplyMessage: Boolean = false, isReplyMessage: Boolean = false,
completionId: String? = null, completionId: String? = null,
serverMessageId: Int? = null, serverMessageId: Int? = null,
allowInlineReply: Boolean = true,
) { ) {
val builder = val builder =
NotificationCompat NotificationCompat
@@ -262,7 +309,7 @@ class ChatNotificationService : Service() {
) )
} }
if (!sessionId.isNullOrBlank()) { if (allowInlineReply && !sessionId.isNullOrBlank()) {
val replyLabel = getString(R.string.notif_reply_placeholder) val replyLabel = getString(R.string.notif_reply_placeholder)
val remoteInput = val remoteInput =
RemoteInput RemoteInput
@@ -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)
}
@@ -35,6 +35,12 @@ data class ToolOutputRiskData(
val redacted: Boolean, val redacted: Boolean,
) )
/** Durable evidence about a locally-created row; UNKNOWN must not be treated as proof of delivery. */
enum class MessageProvenance {
UNKNOWN,
LOCAL_PENDING,
}
data class ChatMessage( data class ChatMessage(
val id: String = UUID.randomUUID().toString(), val id: String = UUID.randomUUID().toString(),
val role: MessageRole, val role: MessageRole,
@@ -91,12 +97,26 @@ data class ChatMessage(
val restId: String? = null, val restId: String? = null,
/** Cache insertion sequence for unconfirmed local rows; null before first persistence. */ /** Cache insertion sequence for unconfirmed local rows; null before first persistence. */
val localOrder: Long? = null, val localOrder: Long? = null,
/** Persisted before prompt submission so process death cannot turn an unsent prompt into old history. */
val messageProvenance: MessageProvenance = MessageProvenance.UNKNOWN,
/** Read from history, not observed live in this view. Never persisted as delivery state. */
val isHistoricalCache: Boolean = false,
/**
* 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. */ /** Cached REST rows already carry their canonical identity in the persisted primary key. */
internal val ChatMessage.canonicalRestId: String? internal val ChatMessage.canonicalRestId: String?
get() = restId ?: id.takeIf { it.startsWith("rest-") } get() = restId ?: id.takeIf { it.startsWith("rest-") }
/** A persisted RUNNING snapshot is not evidence of current tool activity. */
internal val ChatMessage.isToolRunning: Boolean
get() = toolStatus == ToolStatus.RUNNING && !isHistoricalCache
/** /**
* Single live transcript log entry for subagent execution. * Single live transcript log entry for subagent execution.
*/ */
@@ -84,4 +84,8 @@ open class ChatPersistenceRepository(
suspend fun clearMessagesForSession(sessionId: String) { suspend fun clearMessagesForSession(sessionId: String) {
daoProvider().deleteMessagesForSession(sessionId) daoProvider().deleteMessagesForSession(sessionId)
} }
suspend fun deleteMessage(id: String) {
daoProvider().deleteUnconfirmedMessage(id)
}
} }
@@ -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<ChatMessage>,
pendingSends: List<PendingSend>,
): List<ChatMessage> {
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
}
}
@@ -64,9 +64,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -76,12 +78,15 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.ExternalActivityLifecycleGuard import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.HistoryScreen import com.m57.hermescontrol.HistoryScreen
import com.m57.hermescontrol.LogsScreen
import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource 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.model.reasoningSupport
import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
@@ -102,6 +107,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
import com.m57.hermescontrol.ui.chat.components.ReloginDialog 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.SearchBarRow
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
@@ -167,7 +173,11 @@ fun ChatScreen(
// Snapshot-backed search state — read directly so only the scopes that // Snapshot-backed search state — read directly so only the scopes that
// read its fields recompose on search changes (bar, matched bubbles). // read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState 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 lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current val context = LocalContext.current
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) } var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
@@ -190,6 +200,7 @@ fun ChatScreen(
} }
Lifecycle.Event.ON_RESUME -> { Lifecycle.Event.ON_RESUME -> {
viewModel.refreshSettings()
connectorsViewModel.onResume() connectorsViewModel.onResume()
val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted
val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted } val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted }
@@ -309,7 +320,7 @@ fun ChatScreen(
// streaming tail. // streaming tail.
LaunchedEffect( LaunchedEffect(
timelineState.isHistorical, timelineState.isHistorical,
state.messages, displayedMessages,
streamingState.streamingMessage, streamingState.streamingMessage,
streamingState.isThinking, streamingState.isThinking,
state.subagentIndicators, state.subagentIndicators,
@@ -323,13 +334,13 @@ fun ChatScreen(
scrollController.onTailChanged( scrollController.onTailChanged(
tailKey = tailKey =
tailContentKey( tailContentKey(
messages = state.messages, messages = displayedMessages,
streamingMessage = streamingState.streamingMessage, streamingMessage = streamingState.streamingMessage,
isThinking = streamingState.isThinking, isThinking = streamingState.isThinking,
subagentIndicators = state.subagentIndicators, subagentIndicators = state.subagentIndicators,
clarifyRequest = state.clarifyRequest, clarifyRequest = state.clarifyRequest,
), ),
messageCount = state.messages.size, messageCount = displayedMessages.size,
) )
} }
LaunchedEffect(timelineState.historyAnchorRowId) { LaunchedEffect(timelineState.historyAnchorRowId) {
@@ -429,12 +440,16 @@ fun ChatScreen(
onAddAttachments = { attachments -> onAddAttachments = { attachments ->
viewModel.addAttachments(attachments) viewModel.addAttachments(attachments)
}, },
onVoiceNoteRecorded = { file ->
viewModel.sendVoiceNote(file)
},
onShowMessage = { msg -> onShowMessage = { msg ->
scrollScope.launch { scrollScope.launch {
snackbarHostState.showSnackbar(msg) snackbarHostState.showSnackbar(msg)
} }
}, },
launchExternalActivity = launchExternalActivity, launchExternalActivity = launchExternalActivity,
isTranscribingVoiceNote = state.isTranscribingVoiceNote,
context = context, context = context,
) )
@@ -784,6 +799,39 @@ fun ChatScreen(
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath, savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
openingAttachmentPath = state.openingAttachmentPath, openingAttachmentPath = state.openingAttachmentPath,
onImageClick = { viewingImage = it }, 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 // Loading overlay
@@ -834,6 +882,12 @@ fun ChatScreen(
}, },
) )
com.m57.hermescontrol.ui.chat.components.PendingSendPanel(
sends = state.pendingSends,
mainTurnBusy = state.isMainTurnBusy,
onSendNow = viewModel::sendQueuedNow,
)
ChatInputBar( ChatInputBar(
inputFieldValue = inputFieldValue, inputFieldValue = inputFieldValue,
onInputChange = { inputFieldValue = it }, onInputChange = { inputFieldValue = it },
@@ -844,9 +898,23 @@ fun ChatScreen(
scrollController.jumpToBottom(animated = true) scrollController.jumpToBottom(animated = true)
} }
}, },
onBusySend = { mode ->
if (viewModel.sendMessage(inputFieldValue.text, mode)) {
inputFieldValue = TextFieldValue("")
scrollController.jumpToBottom(animated = true)
}
},
onMicTap = mediaLaunchers.onMicTap, 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, isAgentTyping = state.isAgentTyping,
isMainTurnBusy = state.isMainTurnBusy,
canInterrupt = state.canInterrupt,
isConnected = state.isConnected, isConnected = state.isConnected,
isSessionReady = state.isSessionReady && !timelineState.isHistorical, isSessionReady = state.isSessionReady && !timelineState.isHistorical,
sessionPreparationFailed = state.resumeError != null, sessionPreparationFailed = state.resumeError != null,
@@ -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<Attachment> = 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<ChatMessage>,
pending: List<PendingSend>,
): Set<String> {
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<PendingSend> =
prefs?.getString("rows", null)?.let { raw ->
runCatching { json.decodeFromString<List<PendingSend>>(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<PendingSend> = 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<PendingSend>) {
if (prefs != null && !prefs.edit().putString("rows", json.encodeToString(next)).commit()) {
error("Could not save chat send queue")
}
rows = next
}
}
@@ -49,6 +49,7 @@ internal fun mapServerMessages(
} }
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>() val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
val verifierContentByRestIndex = mutableMapOf<Int, String>()
if (!isPagingOlder) { if (!isPagingOlder) {
// #129: reserve durable identity before matching any repeated live prose. // #129: reserve durable identity before matching any repeated live prose.
// REST-only recovery remains fail-closed when the target row is absent. // 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 } 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. // 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 } val match = remaining.indexOfLast { it.content.trim() == content }
if (match >= 0) reasoningSources[index] = remaining.removeAt(match) 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<ChatMessage>() val mapped = mutableListOf<ChatMessage>()
@@ -166,6 +201,8 @@ internal fun mapServerMessages(
var finalContent = var finalContent =
if (role == MessageRole.USER) { if (role == MessageRole.USER) {
stripGatewayAttachedContext(stripGatewaySteerWrapper(rawContent)) stripGatewayAttachedContext(stripGatewaySteerWrapper(rawContent))
} else if (role == MessageRole.ASSISTANT && verifierContentByRestIndex.containsKey(index)) {
verifierContentByRestIndex[index] ?: rawContent
} else { } else {
rawContent rawContent
} }
@@ -227,6 +264,17 @@ internal fun mapServerMessages(
val matches = matchTranscriptMessages(mappedTools, liveTools) val matches = matchTranscriptMessages(mappedTools, liveTools)
val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] } val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] }
return mapped.map { message -> return mapped.map { message ->
toolsById[message.id]?.copy(restId = message.canonicalRestId) ?: message val local = toolsById[message.id]
if (local?.isHistoricalCache == true && local.toolStatus == ToolStatus.RUNNING) {
// A canonical tool-result row settles a cached tool.start, not the reverse.
local.copy(
restId = message.canonicalRestId,
content = message.content,
toolStatus = ToolStatus.COMPLETED,
isHistoricalCache = false,
)
} else {
local?.copy(restId = message.canonicalRestId) ?: message
}
} }
} }
@@ -185,6 +185,14 @@ internal class TranscriptComparison(
val tb = trimmed.getOrPut(b.content) { b.content.trim() } val tb = trimmed.getOrPut(b.content) { b.content.trim() }
if (a.role == MessageRole.USER && (ta.startsWith("/") || tb.startsWith("/"))) return false if (a.role == MessageRole.USER && (ta.startsWith("/") || tb.startsWith("/"))) return false
if (ta == tb) return true 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 && if (a.role == MessageRole.USER &&
captions.getOrPut(ta) { stripAttachmentRefLines(ta) } == captions.getOrPut(ta) { stripAttachmentRefLines(ta) } ==
captions.getOrPut(tb) { stripAttachmentRefLines(tb) } captions.getOrPut(tb) { stripAttachmentRefLines(tb) }
@@ -250,7 +258,17 @@ internal fun matchTranscriptMessages(
) && ) &&
// Distinct IDs within the same source are separate occurrences, not echoes. // Distinct IDs within the same source are separate occurrences, not echoes.
((message.canonicalRestId == null) != (other.canonicalRestId == null)) && ((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) { if (match != null) {
used[match] = true used[match] = true
@@ -317,6 +335,7 @@ internal fun dedupeCachedMessages(
restId = it.canonicalRestId, restId = it.canonicalRestId,
completionId = message.completionId ?: it.completionId, completionId = message.completionId ?: it.completionId,
displayKind = message.displayKind ?: it.displayKind, displayKind = message.displayKind ?: it.displayKind,
isRestoredUnconfirmed = false,
) )
} ?: message } ?: message
} }
@@ -341,13 +360,53 @@ internal fun mergeCachedTranscriptPage(
incoming incoming
.mapIndexedNotNull { index, message -> .mapIndexedNotNull { index, message ->
val match = matches[index] ?: return@mapIndexedNotNull null val match = matches[index] ?: return@mapIndexedNotNull null
val rich = if (match.id.startsWith("rest-") && !message.id.startsWith("rest-")) message else match val 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
// never replace a canonical server payload/status already on screen.
message.isHistoricalCache && match.canonicalRestId != null -> {
match.copy(
toolName = match.toolName ?: message.toolName,
toolCallId = match.toolCallId.ifBlank { message.toolCallId },
attachments = match.attachments ?: message.attachments,
finishTimestamp = match.finishTimestamp ?: message.finishTimestamp,
tokenCount = match.tokenCount ?: message.tokenCount,
tps = match.tps ?: message.tps,
)
}
match.id.startsWith("rest-") && !message.id.startsWith("rest-") -> {
message
}
else -> {
match
}
}
match.id to match.id to
rich.copy( rich.copy(
id = match.id, id = match.id,
restId = message.canonicalRestId ?: match.canonicalRestId, content = preservedContent ?: rich.content,
restId = match.canonicalRestId ?: message.canonicalRestId,
completionId = match.completionId ?: message.completionId, completionId = match.completionId ?: message.completionId,
displayKind = message.displayKind ?: match.displayKind, displayKind = match.displayKind ?: message.displayKind,
isRestoredUnconfirmed =
match.isRestoredUnconfirmed && message.isRestoredUnconfirmed &&
match.canonicalRestId == null && message.canonicalRestId == null,
) )
}.toMap() }.toMap()
val resolvedOrders = val resolvedOrders =
@@ -381,13 +440,24 @@ internal fun mergeTranscriptWithLive(
match.copy( match.copy(
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id }, restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
displayKind = message.displayKind ?: match.displayKind, displayKind = message.displayKind ?: match.displayKind,
isHistoricalCache = false,
isRestoredUnconfirmed = false,
) )
} }
preserveLiveIds && match != null && !match.id.startsWith("rest-") -> { 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( match.copy(
restId = message.canonicalRestId ?: match.canonicalRestId, restId = message.canonicalRestId ?: match.canonicalRestId,
content = message.content, content = mergedContent,
timestamp = message.timestamp, timestamp = message.timestamp,
isStreaming = message.isStreaming, isStreaming = message.isStreaming,
reasoningText = message.reasoningText.ifBlank { match.reasoningText }, reasoningText = message.reasoningText.ifBlank { match.reasoningText },
@@ -398,11 +468,28 @@ internal fun mergeTranscriptWithLive(
displayKind = message.displayKind ?: match.displayKind, displayKind = message.displayKind ?: match.displayKind,
tokenCount = message.tokenCount ?: match.tokenCount, tokenCount = message.tokenCount ?: match.tokenCount,
completionId = message.completionId ?: match.completionId, completionId = message.completionId ?: match.completionId,
isHistoricalCache = false,
isRestoredUnconfirmed = false,
) )
} }
else -> { else -> {
message.copy(completionId = message.completionId ?: match?.completionId) 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,
isRestoredUnconfirmed = false,
)
} }
} }
} }
@@ -431,6 +518,8 @@ private fun List<ChatMessage>.inTranscriptOrder(
resolvedOrders: Map<String, Long>, resolvedOrders: Map<String, Long>,
): List<ChatMessage> { ): List<ChatMessage> {
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
// Session-start markers use -1 and must remain before unresolved cached history.
val beforeCanonical = (mapNotNull { it.canonicalOrder }.filter { it >= 0L }.minOrNull() ?: 0L) - 1L
var precedingCanonical: Long? = null var precedingCanonical: Long? = null
var hasPendingPredecessor = false var hasPendingPredecessor = false
var pendingLocalOrder: Long? = null var pendingLocalOrder: Long? = null
@@ -448,9 +537,18 @@ private fun List<ChatMessage>.inTranscriptOrder(
Long.MAX_VALUE Long.MAX_VALUE
} else { } else {
precedingCanonical?.takeIf { it >= 0L } precedingCanonical?.takeIf { it >= 0L }
?: latestCanonical ?: if (message.role == MessageRole.USER) {
precedingCanonical ?: latestCanonical
} else {
latestCanonical
}
} }
if (hasPendingPredecessor) pendingOrderByLocal[message.id] = pendingLocalOrder ?: Long.MAX_VALUE if (hasPendingPredecessor) pendingOrderByLocal[message.id] = pendingLocalOrder ?: Long.MAX_VALUE
} else if (message.isHistoricalCache || message.isRestoredUnconfirmed) {
// Room groups UUID-only rows after all confirmed rows; that predecessor is
// 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 { } else {
hasPendingPredecessor = true hasPendingPredecessor = true
pendingLocalOrder = message.localOrder pendingLocalOrder = message.localOrder
@@ -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
}
}
File diff suppressed because it is too large. Load diff
@@ -69,6 +69,40 @@ private fun stripSealedOrphanPrefix(
* Android or ViewModel classes. Easy to unit test. * Android or ViewModel classes. Easy to unit test.
*/ */
object ChatWsEventReducer { 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<String, Any?>,
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( fun reduce(
state: ChatUiState, state: ChatUiState,
streamingState: StreamingState, streamingState: StreamingState,
@@ -239,6 +273,18 @@ object ChatWsEventReducer {
streamingState: StreamingState, streamingState: StreamingState,
event: WsEvent.MessageStart, event: WsEvent.MessageStart,
): ReducerResult { ): 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 = val msg =
ChatMessage( ChatMessage(
role = MessageRole.ASSISTANT, role = MessageRole.ASSISTANT,
@@ -252,10 +298,10 @@ object ChatWsEventReducer {
val effects = mutableListOf<ReducerEffect>() val effects = mutableListOf<ReducerEffect>()
val cleanedSubagents = val cleanedSubagents =
if (state.subagentIndicators.all { it.isComplete || it.isFailed }) { if (turnState.subagentIndicators.all { it.isComplete || it.isFailed }) {
emptyList() emptyList()
} else { } else {
state.subagentIndicators turnState.subagentIndicators
} }
// Build new state: finalize any orphan streaming message, then set the new one // Build new state: finalize any orphan streaming message, then set the new one
@@ -270,13 +316,13 @@ object ChatWsEventReducer {
tps = null, tps = null,
) )
orphan = finalized orphan = finalized
state.copy( turnState.copy(
messages = state.messages.upsertById(finalized), messages = turnState.messages.upsertById(finalized),
isAgentTyping = true, isAgentTyping = true,
subagentIndicators = cleanedSubagents, subagentIndicators = cleanedSubagents,
) )
} else { } else {
state.copy( turnState.copy(
isAgentTyping = true, isAgentTyping = true,
subagentIndicators = cleanedSubagents, subagentIndicators = cleanedSubagents,
) )
@@ -406,6 +452,8 @@ object ChatWsEventReducer {
streamingState: StreamingState, streamingState: StreamingState,
event: WsEvent.MessageComplete, event: WsEvent.MessageComplete,
): ReducerResult { ): ReducerResult {
val failure = replyFailureFromPayload(event.rawPayload, event.text)
if (failure != null) return onReplyFailure(state, streamingState, event, failure)
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot) val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline) 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 ─────────────────────────────────────────────────── // ── MessageDone ───────────────────────────────────────────────────
private fun onMessageDone( private fun onMessageDone(
@@ -681,6 +809,7 @@ object ChatWsEventReducer {
messages[toolIdx].copy( messages[toolIdx].copy(
toolStatus = ToolStatus.COMPLETED, toolStatus = ToolStatus.COMPLETED,
content = contentJson, content = contentJson,
isHistoricalCache = false,
) )
messages[toolIdx] = updated messages[toolIdx] = updated
@@ -928,7 +1057,11 @@ object ChatWsEventReducer {
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING) val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState) if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(progressPreview = event.preview ?: "") messages[toolIdx] =
messages[toolIdx].copy(
progressPreview = event.preview ?: "",
isHistoricalCache = false,
)
return ReducerResult( return ReducerResult(
state = state.copy(messages = messages), state = state.copy(messages = messages),
streamingState = streamingState, streamingState = streamingState,
@@ -944,14 +1077,14 @@ object ChatWsEventReducer {
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING) val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState) if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(progressPreview = "") messages[toolIdx] = messages[toolIdx].copy(progressPreview = "", isHistoricalCache = false)
return ReducerResult( return ReducerResult(
state = state.copy(messages = messages), state = state.copy(messages = messages),
streamingState = streamingState, streamingState = streamingState,
) )
} }
/** Match stable tool IDs first, with name matching for legacy events. */ /** Match an explicit stable ID exclusively; use name matching only when legacy events omit the ID. */
private fun findToolIndex( private fun findToolIndex(
messages: List<ChatMessage>, messages: List<ChatMessage>,
name: String?, name: String?,
@@ -965,7 +1098,8 @@ object ChatWsEventReducer {
it.toolCallId == toolId && it.toolCallId == toolId &&
it.toolStatus == status it.toolStatus == status
} }
if (byId >= 0) return byId // An explicit identity must never claim a different historical invocation.
return byId
} }
return messages.indexOfLast { return messages.indexOfLast {
@@ -443,7 +443,15 @@ fun MarkdownText(
} }
is MdBlock.Table -> { 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 -> { is MdBlock.Footnotes -> {
@@ -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<String, Any?>?,
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)((?<![\\w-])[\\\"']?[\\w-]{0,128}" +
"(?:token|secret|password|passwd|api[_-]?key|authorization|cookie)[\\w-]{0,128}" +
"[\\\"']?\\s*[:=]\\s*)(?:\\\"[^\\\"]*\\\"?|'[^']*'?|[^\\r\\n,;}]+)",
)
private val BEARER = Regex("(?i)\\bBearer\\s+[A-Za-z0-9._~+/=-]+")
private val PROVIDER_KEY = Regex("\\b(?:sk-[A-Za-z0-9_-]+|gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)\\b")
@@ -143,8 +143,8 @@ internal fun ToolBubble(
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
val view = val view =
remember(message.content, message.toolName, message.toolStatus) { remember(message.content, message.toolName, message.isToolRunning) {
parseToolOutput(message.content, message.toolName, message.toolStatus == ToolStatus.RUNNING) parseToolOutput(message.content, message.toolName, message.isToolRunning)
} }
val config = ToolSchemaRegistry.getDisplayConfig(message.toolName) val config = ToolSchemaRegistry.getDisplayConfig(message.toolName)
@@ -182,7 +182,7 @@ internal fun ToolBubble(
HeaderRow(message, config, contentColor, statusColors) HeaderRow(message, config, contentColor, statusColors)
// ── Tool progress preview (tool.progress) ── // ── Tool progress preview (tool.progress) ──
if (message.toolStatus == ToolStatus.RUNNING && !message.progressPreview.isNullOrEmpty()) { if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) {
Text( Text(
text = message.progressPreview, text = message.progressPreview,
style = style =
@@ -534,7 +534,7 @@ private fun HeaderRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
// Status icon or spinner // Status icon or spinner
if (message.toolStatus == ToolStatus.RUNNING) { if (message.isToolRunning) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
@@ -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
}
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat.components package com.m57.hermescontrol.ui.chat.components
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -37,6 +38,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -59,6 +62,7 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment 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.model.ProfileInfo
import com.m57.hermescontrol.data.ws.CommandBlocklist import com.m57.hermescontrol.data.ws.CommandBlocklist
import com.m57.hermescontrol.data.ws.CommandCatalog import com.m57.hermescontrol.data.ws.CommandCatalog
@@ -75,9 +79,12 @@ fun ChatInputBar(
inputFieldValue: TextFieldValue, inputFieldValue: TextFieldValue,
onInputChange: (TextFieldValue) -> Unit, onInputChange: (TextFieldValue) -> Unit,
onSend: () -> Unit, onSend: () -> Unit,
onBusySend: (BusySendMode) -> Unit = {},
onMicTap: () -> Unit, onMicTap: () -> Unit,
isListening: Boolean, isListening: Boolean,
isAgentTyping: Boolean, isAgentTyping: Boolean,
isMainTurnBusy: Boolean = false,
canInterrupt: Boolean = false,
isConnected: Boolean, isConnected: Boolean,
commandCatalog: CommandCatalog, commandCatalog: CommandCatalog,
isSessionReady: Boolean, isSessionReady: Boolean,
@@ -103,6 +110,12 @@ fun ChatInputBar(
showModelProvider: Boolean = false, showModelProvider: Boolean = false,
reasoningWireLevel: String? = null, reasoningWireLevel: String? = null,
pendingReasoningLevel: String? = null, pendingReasoningLevel: String? = null,
onMicHoldStart: () -> Unit = {},
onMicHoldEnd: () -> Unit = {},
onMicHoldCancel: () -> Unit = {},
isRecordingVoice: Boolean = false,
voiceNoteAmplitude: State<Float> = remember { mutableStateOf(0f) },
onStopGeneration: () -> Unit = {},
) { ) {
// Allow sending while the agent is mid-turn or awaiting approval: the // 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 // gateway's prompt.submit busy-input policy queues it as the next turn
@@ -111,10 +124,14 @@ fun ChatInputBar(
val canSend = val canSend =
pendingReasoningLevel == null && pendingReasoningLevel == null &&
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady) 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 // Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) } var showAttachmentTray by remember { mutableStateOf(false) }
val palette = composerPalette() val palette = composerPalette()
BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false }
AnimatedVisibility( AnimatedVisibility(
visible = true, visible = true,
@@ -260,6 +277,12 @@ fun ChatInputBar(
} }
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
if (isRecordingVoice) {
VoiceNoteRecordingPanel(
amplitude = voiceNoteAmplitude,
modifier = Modifier.weight(1f),
)
} else {
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
BasicTextField( BasicTextField(
value = inputFieldValue, value = inputFieldValue,
@@ -291,7 +314,9 @@ fun ChatInputBar(
Alignment.CenterStart Alignment.CenterStart
}, },
) { ) {
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { CompositionLocalProvider(
LocalLayoutDirection provides inputLayoutDirection,
) {
if (inputFieldValue.text.isEmpty()) { if (inputFieldValue.text.isEmpty()) {
Text( Text(
text = placeholderText, text = placeholderText,
@@ -311,6 +336,7 @@ fun ChatInputBar(
) )
} }
} }
}
// ── BOTTOM ROW: Toolbar ── // ── BOTTOM ROW: Toolbar ──
ComposerToolbar( ComposerToolbar(
@@ -319,12 +345,20 @@ fun ChatInputBar(
reasoningLevel = reasoningLevel, reasoningLevel = reasoningLevel,
isListening = isListening, isListening = isListening,
canSend = canSend, canSend = canSend,
showSend = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty(), showSend = hasDraft,
showQueue = showBusyActions,
onSend = onSend, onSend = onSend,
onQueue = { onBusySend(BusySendMode.QUEUE) },
onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) },
canInterrupt = canInterrupt,
onStopGeneration = onStopGeneration,
onAttachTap = { showAttachmentTray = !showAttachmentTray }, onAttachTap = { showAttachmentTray = !showAttachmentTray },
onModelTap = onModelTap, onModelTap = onModelTap,
onReasoningSelected = onReasoningTap, onReasoningSelected = onReasoningTap,
onMicTap = onMicTap, onMicTap = onMicTap,
onMicHoldStart = onMicHoldStart,
onMicHoldEnd = onMicHoldEnd,
onMicHoldCancel = onMicHoldCancel,
modifier = Modifier.testTag("chat_composer_toolbar"), modifier = Modifier.testTag("chat_composer_toolbar"),
canDisableReasoning = canDisableReasoning, canDisableReasoning = canDisableReasoning,
supportsReasoning = supportsReasoning, supportsReasoning = supportsReasoning,
@@ -12,6 +12,9 @@ import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ChatInputPolicy import com.m57.hermescontrol.ui.chat.ChatInputPolicy
import com.m57.hermescontrol.ui.chat.SpeechInputHelper import com.m57.hermescontrol.ui.chat.SpeechInputHelper
import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder
import kotlinx.coroutines.delay
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -34,10 +39,15 @@ import java.util.Locale
class ChatMediaLaunchers( class ChatMediaLaunchers(
val isListening: Boolean, val isListening: Boolean,
val isRecordingVoice: Boolean,
val voiceNoteAmplitude: State<Float>,
val onMicTap: () -> Unit, val onMicTap: () -> Unit,
val onCameraTap: () -> Unit, val onCameraTap: () -> Unit,
val onImageTap: () -> Unit, val onImageTap: () -> Unit,
val onFileTap: () -> Unit, val onFileTap: () -> Unit,
val onMicHoldStart: () -> Unit = {},
val onMicHoldEnd: () -> Unit = {},
val onMicHoldCancel: () -> Unit = {},
) )
@Composable @Composable
@@ -46,8 +56,10 @@ fun rememberChatMediaLaunchers(
onInputFieldValueChange: (TextFieldValue) -> Unit, onInputFieldValueChange: (TextFieldValue) -> Unit,
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit, onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
onAddAttachments: (List<Attachment>) -> Unit, onAddAttachments: (List<Attachment>) -> Unit,
onVoiceNoteRecorded: (file: File) -> Unit,
onShowMessage: (String) -> Unit, onShowMessage: (String) -> Unit,
launchExternalActivity: (() -> Unit) -> Unit, launchExternalActivity: (() -> Unit) -> Unit,
isTranscribingVoiceNote: Boolean,
context: Context = LocalContext.current, context: Context = LocalContext.current,
): ChatMediaLaunchers { ): ChatMediaLaunchers {
var isListening by remember { mutableStateOf(false) } var isListening by remember { mutableStateOf(false) }
@@ -57,6 +69,7 @@ fun rememberChatMediaLaunchers(
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange) val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment) val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments) val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded)
val currentOnShowMessage by rememberUpdatedState(onShowMessage) val currentOnShowMessage by rememberUpdatedState(onShowMessage)
val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity) val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity)
@@ -64,6 +77,59 @@ fun rememberChatMediaLaunchers(
val sttNotAvailableMsg = stringResource(R.string.stt_not_available) val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied) val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
val cameraErrorMsg = stringResource(R.string.chat_camera_error) 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) // Speech-to-text recognition launcher (issue #194)
val speechLauncher = 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) // Multi-file picker for attachments (issue #195)
val filePickerLauncher = val filePickerLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
@@ -183,7 +261,14 @@ fun rememberChatMediaLaunchers(
} }
val onMicTap: () -> Unit = { 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 isListening = false
} else if ( } else if (
ContextCompat.checkSelfPermission( 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 = { val onCameraTap: () -> Unit = {
try { try {
val timeStamp = val timeStamp =
@@ -245,13 +369,18 @@ fun rememberChatMediaLaunchers(
} }
} }
return remember(isListening) { return remember(isListening, isRecordingVoice, isTranscribingVoiceNote) {
ChatMediaLaunchers( ChatMediaLaunchers(
isListening = isListening, isListening = isListening || isRecordingVoice,
isRecordingVoice = isRecordingVoice,
voiceNoteAmplitude = voiceNoteAmplitude,
onMicTap = onMicTap, onMicTap = onMicTap,
onCameraTap = onCameraTap, onCameraTap = onCameraTap,
onImageTap = onImageTap, onImageTap = onImageTap,
onFileTap = onFileTap, onFileTap = onFileTap,
onMicHoldStart = onMicHoldStart,
onMicHoldEnd = onMicHoldEnd,
onMicHoldCancel = onMicHoldCancel,
) )
} }
} }
@@ -6,8 +6,12 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -27,6 +31,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add 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.Bolt
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
@@ -48,20 +53,27 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.scale 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.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import kotlinx.coroutines.withTimeoutOrNull
/** /**
* Bottom controls row for the chat composer, rendered inside the composer card. * Bottom controls row for the chat composer, rendered inside the composer card.
@@ -83,6 +95,7 @@ import com.m57.hermescontrol.R
* When [supportsReasoning] is false the model takes no reasoning parameter * When [supportsReasoning] is false the model takes no reasoning parameter
* and the level menu is disabled. * and the level menu is disabled.
*/ */
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ComposerToolbar( fun ComposerToolbar(
isConnected: Boolean, isConnected: Boolean,
@@ -96,7 +109,10 @@ fun ComposerToolbar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
canSend: Boolean = false, canSend: Boolean = false,
showSend: Boolean = canSend, showSend: Boolean = canSend,
showQueue: Boolean = false,
onSend: () -> Unit = {}, onSend: () -> Unit = {},
onQueue: () -> Unit = {},
onStopAndSend: () -> Unit = {},
canDisableReasoning: Boolean? = null, canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null, supportsReasoning: Boolean? = null,
fastMode: Boolean = false, fastMode: Boolean = false,
@@ -107,12 +123,50 @@ fun ComposerToolbar(
reasoningWireLevel: String? = null, reasoningWireLevel: String? = null,
pendingReasoningLevel: String? = null, pendingReasoningLevel: String? = null,
isSessionReady: Boolean = true, isSessionReady: Boolean = true,
canInterrupt: Boolean = false,
onMicHoldStart: () -> Unit = {},
onMicHoldEnd: () -> Unit = {},
onMicHoldCancel: () -> Unit = {},
onStopGeneration: () -> Unit = {},
) { ) {
var showReasoningMenu by remember { mutableStateOf(false) } var showReasoningMenu by remember { mutableStateOf(false) }
val palette = composerPalette() val palette = composerPalette()
val reasoningDisabledForModel = supportsReasoning == false val reasoningDisabledForModel = supportsReasoning == false
val canDisable = canDisableReasoning 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( Row(
modifier = modifier =
modifier modifier
@@ -408,12 +462,32 @@ 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( AnimatedVisibility(
visible = showSend, visible = showSend,
enter = fadeIn() + scaleIn(initialScale = 0.8f), enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut(targetScale = 0.8f), exit = fadeOut() + scaleOut(targetScale = 0.8f),
) { ) {
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( FilledIconButton(
onClick = onMicTap, onClick = onMicTap,
enabled = isConnected, enabled = isConnected,
@@ -421,7 +495,8 @@ fun ComposerToolbar(
modifier = modifier =
Modifier Modifier
.size(ControlSize) .size(ControlSize)
.testTag(if (isListening) "mic_stop_button" else "mic_button"), .testTag(if (isListening) "mic_stop_button" else "mic_button")
.then(flatMicGesture),
) { ) {
Icon( Icon(
imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic, imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic,
@@ -429,34 +504,76 @@ 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 // Action button — send when a send is possible, mic / stop otherwise
FilledIconButton( val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send)
onClick = if (showSend) onSend else onMicTap, val actionEnabled = if (showSend) canSend else isConnected
enabled = if (showSend) canSend else isConnected, Box(
colors =
if (!showSend && isListening) {
listeningIconButtonColors()
} else {
IconButtonDefaults.filledIconButtonColors(
containerColor = palette.action,
contentColor = palette.onAction,
)
},
modifier = modifier =
Modifier Modifier
.size(ControlSize) .size(if (showSend) 48.dp else ControlSize)
.testTag( .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 = {
when { 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" showSend -> "send_button"
isListening -> "mic_stop_button" isListening -> "mic_stop_button"
else -> "mic_button" else -> "mic_button"
}, },
), ).then(if (showSend || canInterrupt) Modifier else actionMicGesture),
contentAlignment = Alignment.Center,
) { ) {
Crossfade( Crossfade(
targetState = targetState =
when { when {
canInterrupt -> ActionGlyph.STOP
showSend -> ActionGlyph.SEND showSend -> ActionGlyph.SEND
isListening -> ActionGlyph.STOP isListening -> ActionGlyph.STOP
else -> ActionGlyph.VOICE else -> ActionGlyph.VOICE
@@ -468,15 +585,34 @@ fun ComposerToolbar(
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Send, imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc), contentDescription = stringResource(R.string.chat_send_desc),
tint = palette.onAction,
) )
} }
ActionGlyph.STOP -> { ActionGlyph.STOP -> {
Icon(imageVector = Icons.Default.Stop, contentDescription = "Stop listening") Icon(
imageVector = Icons.Default.Stop,
contentDescription =
if (canInterrupt) {
stringResource(R.string.chat_voice_stop_generating)
} else {
"Stop listening"
},
tint =
if (isListening && !canInterrupt) {
MaterialTheme.colorScheme.onErrorContainer
} else {
palette.onAction
},
)
} }
ActionGlyph.VOICE -> { ActionGlyph.VOICE -> {
Icon(imageVector = Icons.Outlined.Mic, contentDescription = "Mic") Icon(
imageVector = Icons.Outlined.Mic,
contentDescription = "Mic",
tint = palette.onAction,
)
} }
} }
} }
@@ -581,3 +717,91 @@ fun buildReasoningLabel(
} }
return reqLabel 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
@@ -89,6 +89,7 @@ import com.m57.hermescontrol.theme.CodeTerminalBorder
import com.m57.hermescontrol.theme.CodeTerminalMuted import com.m57.hermescontrol.theme.CodeTerminalMuted
import com.m57.hermescontrol.theme.CodeTerminalText import com.m57.hermescontrol.theme.CodeTerminalText
import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.chat.SubagentIndicator import com.m57.hermescontrol.ui.chat.SubagentIndicator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -101,7 +102,8 @@ import kotlinx.coroutines.withContext
* (reasoning-model thinking steps) before the final answer. * (reasoning-model thinking steps) before the final answer.
* *
* Collapsed: "🧠 Reasoning · {N} steps" with chevron. * 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. * screen height with internal scroll; "Show full" lifts the cap.
* Long-press anywhere: copies the reasoning trace to the clipboard. * Long-press anywhere: copies the reasoning trace to the clipboard.
* Streaming: pulsing indicator at the bottom while [isStreaming]. * Streaming: pulsing indicator at the bottom while [isStreaming].
@@ -180,11 +182,7 @@ fun ReasoningCard(
} }
AnimatedVisibility(visible = expanded) { AnimatedVisibility(visible = expanded) {
Column { Column {
Text( val contentModifier =
text = reasoningText,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier Modifier
.padding(top = 4.dp) .padding(top = 4.dp)
.then( .then(
@@ -198,8 +196,21 @@ fun ReasoningCard(
// axis) that combination throws IllegalStateException. // axis) that combination throws IllegalStateException.
Modifier 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) { if (isStreaming) {
ReasoningPulsingDot(modifier = Modifier.padding(top = 6.dp)) ReasoningPulsingDot(modifier = Modifier.padding(top = 6.dp))
} }
@@ -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<PendingSend>,
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
},
),
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,122 @@
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.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.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.saveable.rememberSaveable
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.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.ReplyFailure
import com.m57.hermescontrol.ui.chat.sanitizeReplyErrorDetails
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReplyErrorCard(
failure: ReplyFailure,
onDismiss: () -> 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)) }
}
}
}
}
@@ -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<Float>,
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<Float>() }
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')}"
}
@@ -20,8 +20,8 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry
import com.m57.hermescontrol.ui.chat.ToolStatus
import com.m57.hermescontrol.ui.chat.components.TypingIndicator import com.m57.hermescontrol.ui.chat.components.TypingIndicator
import com.m57.hermescontrol.ui.chat.isToolRunning
import com.m57.hermescontrol.ui.chat.isUserTurnBoundary import com.m57.hermescontrol.ui.chat.isUserTurnBoundary
/** Live status for an agent turn before visible assistant prose is available. */ /** Live status for an agent turn before visible assistant prose is available. */
@@ -61,7 +61,7 @@ internal fun deriveAgentStatus(
.asReversed() .asReversed()
.firstOrNull { .firstOrNull {
it.role == MessageRole.TOOL && it.role == MessageRole.TOOL &&
it.toolStatus == ToolStatus.RUNNING it.isToolRunning
} }
val hasVisibleStreamingContent = streamingState.streamingMessage?.content?.isNotBlank() == true val hasVisibleStreamingContent = streamingState.streamingMessage?.content?.isNotBlank() == true
@@ -104,8 +104,9 @@ fun FullBleedChatList(
hasOlderMessages: Boolean = false, hasOlderMessages: Boolean = false,
pagingSessionId: String? = null, pagingSessionId: String? = null,
onLoadOlder: () -> Unit = viewModel::loadOlderMessages, onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
replyErrorContent: (@Composable () -> Unit)? = null,
) { ) {
if (messages.isEmpty() && !isLoading && !isAgentTyping) { if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -145,6 +146,7 @@ fun FullBleedChatList(
// A single ordered definition supplies both emitted tail rows and anchor keys. // A single ordered definition supplies both emitted tail rows and anchor keys.
val tailItems = val tailItems =
buildMap<String, @Composable () -> Unit> { buildMap<String, @Composable () -> Unit> {
replyErrorContent?.let { put("reply_error", it) }
agentStatus?.let { status -> agentStatus?.let { status ->
put("agent_status") { put("agent_status") {
AgentStatusIndicator(status = status) AgentStatusIndicator(status = status)
@@ -66,33 +66,84 @@ object MarkdownInlineStyler {
return buildAnnotatedString { return buildAnnotatedString {
var i = 0 var i = 0
val src = text val src = text
while (i < src.length) { while (i < src.length) {
when { when {
// ***bold italic*** // Inline code is opaque to emphasis parsing, and delimiters must match by run length.
src.startsWith("***", i) -> { src[i] == '`' -> {
val end = src.indexOf("***", i + 3) var runEnd = i
if (end != -1) { while (runEnd < src.length && src[runEnd] == '`') runEnd++
val raw = src.substring(i + 3, end) val runLength = runEnd - i
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw var end = runEnd
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { var matchingEnd = -1
append(toAppend) while (end < src.length) {
if (src[end] == '`') {
var candidateEnd = end
while (candidateEnd < src.length && src[candidateEnd] == '`') candidateEnd++
if (candidateEnd - end == runLength) {
matchingEnd = end
break
} }
i = end + 3 end = candidateEnd
} else { } else {
append(src[i]) end++
i++ }
}
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.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) -> { 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) { if (end != -1) {
val raw = src.substring(i + 2, end) val raw = src.substring(i + 2, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw val nested =
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { parseInlineSource(
append(toAppend) raw,
} textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
isRtl,
)
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(nested) }
i = end + 2 i = end + 2
} else { } else {
append(src[i]) append(src[i])
@@ -116,16 +167,24 @@ object MarkdownInlineStyler {
} }
} }
// *italic* // *italic*; bold spans keep their styles while nested text is appended.
src.startsWith("*", i) -> { src.startsWith("*", i) -> {
val end = src.indexOf('*', i + 1) val close = src.indexOf('*', i + 1)
if (end != -1 && end > i + 1) { if (close > i + 1) {
val raw = src.substring(i + 1, end) val raw = src.substring(i + 1, close)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw val italic = SpanStyle(fontStyle = FontStyle.Italic)
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { val nested =
append(toAppend) parseInlineSource(
} raw,
i = end + 1 textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
isRtl,
)
withStyle(italic) { append(nested) }
i = close + 1
} else { } else {
append(src[i]) append(src[i])
i++ i++
@@ -225,16 +284,20 @@ object MarkdownInlineStyler {
val urlEnd = src.indexOf(')', close + 2) val urlEnd = src.indexOf(')', close + 2)
if (urlEnd != -1) { if (urlEnd != -1) {
val label = src.substring(i + 1, close) val label = src.substring(i + 1, close)
val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label
val url = src.substring(close + 2, urlEnd) val url = src.substring(close + 2, urlEnd)
pushLink(LinkAnnotation.Url(url)) pushLink(LinkAnnotation.Url(url))
withStyle( val labelText =
SpanStyle( parseInlineSource(
color = linkColor, label,
textDecoration = TextDecoration.Underline, textColor,
), searchQuery,
) { isCurrentMatch,
append(labelToAppend) linkColor,
highlights,
isRtl,
)
withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) {
append(labelText)
} }
pop() pop()
i = urlEnd + 1 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 // bare URL
URL_PATTERN.matchAt(src, i) != null -> { URL_PATTERN.matchAt(src, i) != null -> {
val match = URL_PATTERN.matchAt(src, i)!! val match = URL_PATTERN.matchAt(src, i)!!
@@ -10,18 +10,19 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign 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.LayoutDirection
import androidx.compose.ui.unit.dp 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 import com.m57.hermescontrol.util.BidiUtils
private val TABLE_COL_WIDTH = 140.dp private val TABLE_COL_WIDTH = 140.dp
@@ -30,6 +31,11 @@ private val TABLE_COL_WIDTH = 140.dp
fun MarkdownTable( fun MarkdownTable(
block: MdBlock.Table, block: MdBlock.Table,
textColor: Color, textColor: Color,
latexMeasurer: LatexMeasurerState,
searchQuery: String,
isCurrentMatch: Boolean,
linkColor: Color,
highlights: SearchHighlightColors,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isRtl = val isRtl =
@@ -49,18 +55,21 @@ fun MarkdownTable(
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
) { ) {
// Header row // Header row
Row(modifier = Modifier.background(headerBg)) { Row(modifier = Modifier.background(headerBg), verticalAlignment = Alignment.Top) {
block.header.forEachIndexed { idx, cell -> block.header.forEachIndexed { idx, cell ->
val cellRtl = BidiUtils.isRtlText(cell) MarkdownInlineText(
Text( text = cell,
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, textColor = textColor,
textAlign = tableTextAlign(alignments.getOrNull(idx)), latexMeasurer = latexMeasurer,
color = textColor,
style = style =
MaterialTheme.typography.bodySmall.copy( MaterialTheme.typography.bodySmall.copy(
fontWeight = FontWeight.Bold, 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 =
Modifier Modifier
.width(TABLE_COL_WIDTH) .width(TABLE_COL_WIDTH)
@@ -71,17 +80,20 @@ fun MarkdownTable(
HorizontalDivider(color = textColor.copy(alpha = 0.25f)) HorizontalDivider(color = textColor.copy(alpha = 0.25f))
// Body rows // Body rows
block.rows.forEach { row -> block.rows.forEach { row ->
Row { Row(verticalAlignment = Alignment.Top) {
row.forEachIndexed { idx, cell -> row.forEachIndexed { idx, cell ->
val cellRtl = BidiUtils.isRtlText(cell) MarkdownInlineText(
Text( text = cell,
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, textColor = textColor,
textAlign = tableTextAlign(alignments.getOrNull(idx)), latexMeasurer = latexMeasurer,
color = textColor,
style = style =
MaterialTheme.typography.bodySmall.copy( 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 =
Modifier Modifier
.width(TABLE_COL_WIDTH) .width(TABLE_COL_WIDTH)
@@ -95,9 +107,9 @@ fun MarkdownTable(
} }
} }
private fun tableTextAlign(align: TableAlign?): TextAlign? = private fun tableTextAlign(align: TableAlign?): TextAlign =
when (align) { when (align) {
TableAlign.CENTER -> TextAlign.Center TableAlign.CENTER -> TextAlign.Center
TableAlign.RIGHT -> TextAlign.End TableAlign.RIGHT -> TextAlign.End
else -> null else -> TextAlign.Start
} }
@@ -193,6 +193,8 @@ internal fun SettingsChatPage(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
ChatSection( ChatSection(
busySendMode = state.busySendMode,
onBusySendModeChange = viewModel::onBusySendModeChange,
typingEffectEnabled = state.typingEffectEnabled, typingEffectEnabled = state.typingEffectEnabled,
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange, onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
typingEffectDelayMs = state.typingEffectDelayMs, typingEffectDelayMs = state.typingEffectDelayMs,
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.config.resolveBaseUrl import com.m57.hermescontrol.data.config.resolveBaseUrl
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SessionListCacheStore 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.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
@@ -37,6 +38,7 @@ data class SettingsUiState(
val testResult: String? = null, val testResult: String? = null,
val isSaved: Boolean = false, val isSaved: Boolean = false,
val typingEffectEnabled: Boolean = false, val typingEffectEnabled: Boolean = false,
val busySendMode: BusySendMode = BusySendMode.CORRECT,
val typingEffectDelayMs: Int = 30, val typingEffectDelayMs: Int = 30,
val chatFontScale: Float = 1.0f, val chatFontScale: Float = 1.0f,
val messageStatsEnabled: Boolean = false, val messageStatsEnabled: Boolean = false,
@@ -85,6 +87,7 @@ class SettingsViewModel(
val useDynamicColors = AuthManager.isUseDynamicColors() val useDynamicColors = AuthManager.isUseDynamicColors()
val themePreset = AuthManager.getThemePreset() val themePreset = AuthManager.getThemePreset()
val typingEffectEnabled = AuthManager.isTypingEffectEnabled() val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
val busySendMode = AuthManager.getBusySendMode()
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs() val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
val chatFontScale = AuthManager.getChatFontScale() val chatFontScale = AuthManager.getChatFontScale()
val messageStatsEnabled = AuthManager.isMessageStatsEnabled() val messageStatsEnabled = AuthManager.isMessageStatsEnabled()
@@ -112,6 +115,7 @@ class SettingsViewModel(
useDynamicColors = useDynamicColors, useDynamicColors = useDynamicColors,
themePreset = themePreset, themePreset = themePreset,
typingEffectEnabled = typingEffectEnabled, typingEffectEnabled = typingEffectEnabled,
busySendMode = busySendMode,
typingEffectDelayMs = typingEffectDelayMs, typingEffectDelayMs = typingEffectDelayMs,
chatFontScale = chatFontScale, chatFontScale = chatFontScale,
messageStatsEnabled = messageStatsEnabled, messageStatsEnabled = messageStatsEnabled,
@@ -336,6 +340,11 @@ class SettingsViewModel(
AuthManager.setTypingEffectEnabled(enabled) AuthManager.setTypingEffectEnabled(enabled)
} }
fun onBusySendModeChange(mode: BusySendMode) {
_uiState.update { it.copy(busySendMode = mode, isSaved = false) }
AuthManager.setBusySendMode(mode)
}
fun onTypingEffectDelayMsChange(delayMs: Int) { fun onTypingEffectDelayMsChange(delayMs: Int) {
_uiState.update { it.copy(typingEffectDelayMs = delayMs, isSaved = false) } _uiState.update { it.copy(typingEffectDelayMs = delayMs, isSaved = false) }
AuthManager.setTypingEffectDelayMs(delayMs) AuthManager.setTypingEffectDelayMs(delayMs)
@@ -38,8 +38,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.ui.chat.label
import com.m57.hermescontrol.ui.settings.SectionCard import com.m57.hermescontrol.ui.settings.SectionCard
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -215,6 +217,8 @@ internal fun ChatSection(
onTokensPerSecondChange: (Boolean) -> Unit = {}, onTokensPerSecondChange: (Boolean) -> Unit = {},
showModelProvider: Boolean = false, showModelProvider: Boolean = false,
onShowModelProviderChange: (Boolean) -> Unit = {}, onShowModelProviderChange: (Boolean) -> Unit = {},
busySendMode: BusySendMode = BusySendMode.CORRECT,
onBusySendModeChange: (BusySendMode) -> Unit = {},
) { ) {
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f) val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
val currentIndex = val currentIndex =
@@ -223,6 +227,38 @@ internal fun ChatSection(
.takeIf { it >= 0 } ?: 1 .takeIf { it >= 0 } ?: 1
SectionCard { 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
+22
View File
@@ -1543,6 +1543,7 @@
<string name="media_player_play_video">تشغيل الفيديو</string> <string name="media_player_play_video">تشغيل الفيديو</string>
<string name="chat_session_preparing">جارٍ تجهيز الجلسة…</string> <string name="chat_session_preparing">جارٍ تجهيز الجلسة…</string>
<string name="chat_session_not_ready">الجلسة غير جاهزة — أعد المحاولة للمتابعة</string> <string name="chat_session_not_ready">الجلسة غير جاهزة — أعد المحاولة للمتابعة</string>
<string name="connection_setup_target_title">إعداد %1$s</string> <string name="connection_setup_target_title">إعداد %1$s</string>
<string name="connection_setup_progress">%1$d من %2$d</string> <string name="connection_setup_progress">%1$d من %2$d</string>
<string name="connection_setup_authorized_no_tools">تم التفويض، لكن الأدوات غير متاحة</string> <string name="connection_setup_authorized_no_tools">تم التفويض، لكن الأدوات غير متاحة</string>
@@ -1556,4 +1557,25 @@
<string name="connection_setup_required">%1$s مطلوب</string> <string name="connection_setup_required">%1$s مطلوب</string>
<string name="connection_setup_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string> <string name="connection_setup_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string>
<string name="connection_setup_pending">بانتظار Hermes…</string> <string name="connection_setup_pending">بانتظار Hermes…</string>
<string name="chat_busy_mode_title">أثناء عمل الوكيل</string>
<string name="chat_busy_mode_desc">اختر كيفية وصول الرسالة الجديدة إلى المحادثة الحالية. تُرسل الرسائل بشكل عادي عند الخمول.</string>
<string name="chat_busy_correct">تصحيح الدور الحالي</string>
<string name="chat_busy_queue">الإرسال في الدور التالي</string>
<string name="chat_busy_guide">توجيه عند النقطة الآمنة التالية</string>
<string name="chat_busy_interrupt">مقاطعة قسرية ثم إرسال</string>
<string name="chat_busy_interrupt_warning">قد توقف المقاطعة القسرية الوكلاء الفرعيين. يسمح الانتظار والتوجيه باستمرار العمل الحالي.</string>
<string name="chat_busy_override">وضع إرسال هذه الرسالة</string>
<string name="chat_busy_override_once">يسري هذا الاختيار مرة واحدة فقط ولا يغير الإعداد الافتراضي المحفوظ.</string>
<string name="chat_pending_sends">رسائل معلّقة</string>
<string name="chat_pending_queued">في قائمة الانتظار على هذا الجهاز</string>
<string name="chat_pending_parked">موقوفة بعد الإيقاف</string>
<string name="chat_pending_sending">جارٍ الإرسال…</string>
<string name="chat_pending_accepted">قبلها الخادم؛ بانتظار ظهورها في السجل. قد تؤدي إعادة الإرسال إلى تكرارها.</string>
<string name="chat_pending_unknown">حالة التسليم غير معروفة؛ قد يؤدي الإرسال مجددًا إلى تكرارها</string>
<string name="chat_pending_rejected">لم تُسلّم؛ اختر الإرسال الآن لإعادة المحاولة</string>
<string name="chat_pending_send_now">إرسال الآن</string>
<string name="chat_pending_send_now_warning">قد يقطع إرسال رسالة معلّقة الآن العمل الجاري ويوقف الوكلاء الفرعيين.</string>
<string name="chat_pending_send_again">إرسال مجددًا</string>
<string name="chat_pending_attachments">ستُرسل المرفقات مع هذه الرسالة.</string>
</resources> </resources>
+22
View File
@@ -1199,6 +1199,7 @@
<string name="media_player_play_video">비디오 재생</string> <string name="media_player_play_video">비디오 재생</string>
<string name="chat_session_preparing">세션 준비 중…</string> <string name="chat_session_preparing">세션 준비 중…</string>
<string name="chat_session_not_ready">세션이 준비되지 않았습니다. 다시 시도하세요</string> <string name="chat_session_not_ready">세션이 준비되지 않았습니다. 다시 시도하세요</string>
<string name="connection_setup_target_title">%1$s 설정</string> <string name="connection_setup_target_title">%1$s 설정</string>
<string name="connection_setup_progress">%2$d개 중 %1$d개</string> <string name="connection_setup_progress">%2$d개 중 %1$d개</string>
<string name="connection_setup_authorized_no_tools">인증되었지만 도구를 사용할 수 없습니다</string> <string name="connection_setup_authorized_no_tools">인증되었지만 도구를 사용할 수 없습니다</string>
@@ -1239,4 +1240,25 @@
<string name="kanban_attachment_error">첨부 파일 오류: %1$s</string> <string name="kanban_attachment_error">첨부 파일 오류: %1$s</string>
<string name="kanban_active_workers">활성 작업자</string> <string name="kanban_active_workers">활성 작업자</string>
<string name="kanban_no_active_workers">활성 작업자가 없습니다</string> <string name="kanban_no_active_workers">활성 작업자가 없습니다</string>
<string name="chat_busy_mode_title">에이전트가 작업 중일 때</string>
<string name="chat_busy_mode_desc">새 메시지를 현재 채팅에 전달할 방식을 선택합니다. 유휴 상태에서는 일반 전송됩니다.</string>
<string name="chat_busy_correct">현재 턴 수정</string>
<string name="chat_busy_queue">다음 턴 대기열에 추가</string>
<string name="chat_busy_guide">다음 안전 지점에서 안내</string>
<string name="chat_busy_interrupt">강제 중단 후 전송</string>
<string name="chat_busy_interrupt_warning">강제 중단은 하위 에이전트를 중지할 수 있습니다. 대기열과 안내는 현재 작업을 계속합니다.</string>
<string name="chat_busy_override">이 메시지의 전송 방식</string>
<string name="chat_busy_override_once">이 선택은 한 번만 적용되며 저장된 기본값은 바뀌지 않습니다.</string>
<string name="chat_pending_sends">대기 중인 메시지</string>
<string name="chat_pending_queued">이 기기에서 대기 중</string>
<string name="chat_pending_parked">중지 후 보류됨</string>
<string name="chat_pending_sending">전송 중…</string>
<string name="chat_pending_accepted">서버에서 수락됨; 대화 기록 대기 중. 다시 보내면 중복될 수 있습니다.</string>
<string name="chat_pending_unknown">전달 여부를 알 수 없습니다. 다시 보내면 중복될 수 있습니다.</string>
<string name="chat_pending_rejected">전달되지 않았습니다. 지금 보내기를 눌러 재시도하세요.</string>
<string name="chat_pending_send_now">지금 보내기</string>
<string name="chat_pending_send_now_warning">대기 중인 메시지를 지금 보내면 진행 중인 작업이 중단되고 하위 에이전트가 멈출 수 있습니다.</string>
<string name="chat_pending_send_again">다시 보내기</string>
<string name="chat_pending_attachments">첨부 파일도 함께 전송됩니다.</string>
</resources> </resources>
+120
View File
@@ -1509,4 +1509,124 @@
<string name="connection_setup_required">%1$s为必填项</string> <string name="connection_setup_required">%1$s为必填项</string>
<string name="connection_setup_request_failed">无法发送设置响应,请重试。</string> <string name="connection_setup_request_failed">无法发送设置响应,请重试。</string>
<string name="connection_setup_pending">正在等待 Hermes…</string> <string name="connection_setup_pending">正在等待 Hermes…</string>
<string name="chat_busy_mode_title">智能体工作时</string>
<string name="chat_busy_mode_desc">选择新消息如何送达当前聊天。空闲时正常发送。</string>
<string name="chat_busy_correct">修正当前轮次</string>
<string name="chat_busy_queue">排队等待下一轮</string>
<string name="chat_busy_guide">在下一个安全点提供指导</string>
<string name="chat_busy_interrupt">强制中断后发送</string>
<string name="chat_busy_interrupt_warning">强制中断可能停止子智能体。排队和指导会让当前工作继续。</string>
<string name="chat_busy_override">本条消息的发送方式</string>
<string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</string>
<string name="chat_pending_sends">待发送消息</string>
<string name="chat_pending_queued">已在本机排队</string>
<string name="chat_pending_parked">停止后已暂停</string>
<string name="chat_pending_sending">正在发送…</string>
<string name="chat_pending_accepted">服务器已接收,等待写入聊天记录。再次发送可能造成重复。</string>
<string name="chat_pending_unknown">送达状态未知;再次发送可能重复</string>
<string name="chat_pending_rejected">未送达;点按“立即发送”重试</string>
<string name="chat_pending_send_now">立即发送</string>
<string name="chat_pending_send_now_warning">现在发送待处理消息可能会中断当前任务并停止子代理。</string>
<string name="chat_pending_send_again">再次发送</string>
<string name="chat_pending_attachments">附件将随这条消息发送。</string>
<string name="chat_action_timeline">时间线</string>
<string name="chat_history_window_latest">回到最新</string>
<string name="chat_history_window_more_both">此窗口之前和之后还有更多消息。</string>
<string name="chat_history_window_more_newer">此窗口之后还有更新的消息。</string>
<string name="chat_history_window_more_older">此窗口之前还有更早的消息。</string>
<string name="chat_history_window_title">正在查看更早的消息</string>
<string name="chat_reasoning_menu_desc">推理选项</string>
<string name="chat_timeline_empty">还没有提示词。</string>
<string name="chat_timeline_load_more">加载更多</string>
<string name="chat_timeline_prompt_fallback">更早的提示词</string>
<string name="chat_timeline_refresh">刷新</string>
<string name="chat_timeline_retry">重试</string>
<string name="chat_timeline_subtitle">不用加载完整对话,就能跳到某条提示词。</string>
<string name="chat_timeline_title">对话时间线</string>
<string name="connect_error_lan_permission_denied">本地网络访问被拒绝。请在系统设置中授予“本地网络”权限,或改用远程网关地址连接。</string>
<string name="detail_dialog_capabilities">能力</string>
<string name="detail_dialog_commit">提交 SHA</string>
<string name="detail_dialog_docs">文档</string>
<string name="detail_dialog_maintainer">维护者</string>
<string name="detail_dialog_repository">仓库</string>
<string name="detail_dialog_requires_hermes">需要 Hermes</string>
<string name="detail_dialog_tier">层级</string>
<string name="group_chat_action_stop">停止生成</string>
<string name="group_chat_bot_passed">%1$s 本轮未发言</string>
<string name="group_chat_stopped_by_user">用户停止了讨论</string>
<string name="group_chat_tool_no_output">无输出</string>
<string name="group_chat_tool_no_output_success">退出码 0(无输出)</string>
<string name="group_chat_tool_running">运行中…</string>
<string name="kanban_bulk_complete_title">完成 %1$d 项任务</string>
<string name="notif_input_needed">Hermes 需要你的输入</string>
<string name="plugins_catalog_empty_desc">没有匹配筛选条件或搜索词的插件</string>
<string name="plugins_catalog_empty_title">未找到目录插件</string>
<string name="plugins_catalog_filter_all">全部</string>
<string name="plugins_catalog_filter_community">社区</string>
<string name="plugins_catalog_filter_official">官方</string>
<string name="plugins_catalog_installed_badge">已安装</string>
<string name="plugins_catalog_installed_success">插件“%1$s”安装成功</string>
<string name="plugins_catalog_installing">正在安装 %1$s…</string>
<string name="plugins_catalog_maintainer">作者 %1$s</string>
<string name="plugins_catalog_search_placeholder">搜索目录插件…</string>
<string name="plugins_catalog_tier_community">社区</string>
<string name="plugins_catalog_tier_official">官方</string>
<string name="plugins_catalog_update">更新</string>
<string name="plugins_tab_catalog">目录</string>
<string name="plugins_tab_installed">已安装</string>
<string name="sessions_automations_empty_desc">由定时自动化启动的运行会显示在这里。</string>
<string name="sessions_automations_empty_title">暂无自动化历史</string>
<string name="sessions_section_automation_job">任务 %1$s</string>
<string name="sessions_section_automation_runs">已加载 %1$d 次运行</string>
<string name="sessions_section_automation_unknown">自动运行</string>
<string name="sessions_stat_messages_loaded">已加载消息</string>
<string name="sessions_tab_automations">自动化</string>
<string name="sessions_tab_conversations">对话</string>
<string name="settings_sec_vault">凭据保险库</string>
<string name="settings_summary_vault">密码管理器与凭据</string>
<string name="subagent_hide_transcript">隐藏转录</string>
<string name="subagent_inspect_transcript">查看转录</string>
<string name="subagent_subagents_header">子代理</string>
<string name="subagent_transcript_empty">暂无转录输出。</string>
<string name="subagent_transcript_error">无法加载转录</string>
<string name="subagent_transcript_loading">正在加载实时转录…</string>
<string name="subagent_transcript_retry">重试</string>
<string name="subagent_transcript_truncated">滚动显示最近 16KB(更早输出已截断)</string>
<string name="update_dialog_action_install">下载并安装</string>
<string name="update_dialog_action_later">稍后提醒</string>
<string name="update_dialog_action_skip">跳过此版本</string>
<string name="update_dialog_changelog_header">更新内容</string>
<string name="update_dialog_title">更新到 %1$s</string>
<string name="vault_code_action">提交</string>
<string name="vault_code_desc">输入 %1$s 的两步验证或校验码。</string>
<string name="vault_code_dismiss">跳过</string>
<string name="vault_code_label">验证码</string>
<string name="vault_code_title">验证码</string>
<string name="vault_items_empty">保险库中暂无条目</string>
<string name="vault_items_title">已存条目</string>
<string name="vault_lock_all">锁定全部管理器</string>
<string name="vault_lock_all_success">已锁定全部密码管理器</string>
<string name="vault_save_login_action">保存并填充</string>
<string name="vault_save_login_desc">将 %1$s 的账号密码保存到凭据保险库?</string>
<string name="vault_save_login_dismiss">不保存</string>
<string name="vault_save_login_identifier">用户名或邮箱</string>
<string name="vault_save_login_password">密码</string>
<string name="vault_save_login_title">保存登录信息到保险库</string>
<string name="vault_source_installed">已安装</string>
<string name="vault_source_lock">锁定</string>
<string name="vault_source_locked">已锁定</string>
<string name="vault_source_not_installed">未安装</string>
<string name="vault_source_unlock">解锁</string>
<string name="vault_source_unlocked">已解锁</string>
<string name="vault_sources_desc">外部密码管理器为智能体提供自动填充凭据。</string>
<string name="vault_sources_title">密码管理器</string>
<string name="vault_unlock_action">解锁</string>
<string name="vault_unlock_desc">智能体需要凭据才能继续。输入主密码以解锁 %1$s。</string>
<string name="vault_unlock_dialog_confirm">解锁</string>
<string name="vault_unlock_dialog_title">解锁 %1$s</string>
<string name="vault_unlock_dismiss">保持锁定</string>
<string name="vault_unlock_failed">无法解锁 %1$s</string>
<string name="vault_unlock_password_label">主密码</string>
<string name="vault_unlock_success">%1$s 解锁成功</string>
<string name="vault_unlock_title">解锁 %1$s</string>
</resources> </resources>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chat_reply_failed_title">Hermes couldn\'t finish this reply</string>
<string name="chat_reply_failed_description">Something went wrong while Hermes was replying. Open the details or logs to understand what happened.</string>
<string name="chat_reply_failed_details">Details</string>
<string name="chat_reply_failed_details_collapsed">Collapsed</string>
<string name="chat_reply_failed_details_expanded">Expanded</string>
<string name="chat_reply_failed_copy">Copy error details</string>
<string name="chat_reply_failed_logs">Open logs</string>
<string name="chat_reply_failed_share">Share diagnostics</string>
<string name="chat_reply_failed_share_note">Review details before sharing. Diagnostics include this error and the app version, not your conversation or attachments.</string>
<string name="chat_reply_failed_dismiss">Dismiss error</string>
<string name="chat_reply_failed_copied">Error details copied</string>
<string name="chat_reply_failed_share_unavailable">No app available to share diagnostics</string>
</resources>
+26
View File
@@ -4,6 +4,9 @@
<!-- Chat --> <!-- Chat -->
<string name="chat_send_desc">Send</string> <string name="chat_send_desc">Send</string>
<string name="chat_mic_listening">Listening…</string> <string name="chat_mic_listening">Listening…</string>
<string name="chat_voice_record_failed">Could not start voice recording</string>
<string name="chat_voice_slide_to_cancel">Slide to cancel</string>
<string name="chat_voice_stop_generating">Stop generating</string>
<string name="stt_permission_denied">Microphone permission is required for voice input</string> <string name="stt_permission_denied">Microphone permission is required for voice input</string>
<string name="stt_not_available">Speech recognition is not available on this device</string> <string name="stt_not_available">Speech recognition is not available on this device</string>
<string name="chat_send">Send</string> <string name="chat_send">Send</string>
@@ -194,6 +197,29 @@
<string name="chat_input_placeholder_waiting">Waiting for response\u2026</string> <string name="chat_input_placeholder_waiting">Waiting for response\u2026</string>
<string name="chat_input_placeholder_queue">Queued \u2014 sends when agent is free\u2026</string> <string name="chat_input_placeholder_queue">Queued \u2014 sends when agent is free\u2026</string>
<string name="chat_input_placeholder_type_message">Type a message\u2026</string> <string name="chat_input_placeholder_type_message">Type a message\u2026</string>
<string name="chat_busy_mode_title">While the agent is working</string>
<string name="chat_busy_mode_desc">Choose how a new message reaches the current chat. Idle messages send normally.</string>
<string name="chat_busy_correct">Correct current turn</string>
<string name="chat_busy_queue">Queue next turn</string>
<string name="chat_busy_guide">Guide at next safe point</string>
<string name="chat_busy_interrupt">Hard interrupt, then send</string>
<string name="chat_busy_interrupt_warning">Hard interrupt can stop child agents. Queue and guide let current work continue.</string>
<string name="chat_busy_queue_action">Queue</string>
<string name="chat_busy_stop_and_send">Stop &amp; send</string>
<string name="chat_busy_more">More</string>
<string name="chat_busy_override">Send mode for this message</string>
<string name="chat_busy_override_once">This choice applies once; the saved default stays the same.</string>
<string name="chat_pending_sends">Pending messages</string>
<string name="chat_pending_queued">Queued on this device</string>
<string name="chat_pending_parked">Parked after Stop</string>
<string name="chat_pending_sending">Sending\u2026</string>
<string name="chat_pending_accepted">Accepted by server; awaiting transcript. Sending again may duplicate it.</string>
<string name="chat_pending_unknown">Delivery unknown; sending again may duplicate it</string>
<string name="chat_pending_rejected">Not delivered; choose Send now to retry</string>
<string name="chat_pending_send_now">Send now</string>
<string name="chat_pending_send_now_warning">Sending a pending message now may interrupt active work and stop child agents.</string>
<string name="chat_pending_send_again">Send again</string>
<string name="chat_pending_attachments">Attachments will be sent with this message.</string>
<string name="chat_tool_execution_error">Execution Error: %1$s</string> <string name="chat_tool_execution_error">Execution Error: %1$s</string>
<string name="chat_tool_exit_code">Exit Code: %1$d</string> <string name="chat_tool_exit_code">Exit Code: %1$d</string>
<string name="chat_tool_output_omitted">%1$d characters omitted from process output</string> <string name="chat_tool_output_omitted">%1$d characters omitted from process output</string>
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.data.local package com.m57.hermescontrol.data.local
import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageProvenance
import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.ToolStatus import com.m57.hermescontrol.ui.chat.ToolStatus
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -187,6 +188,32 @@ class ChatMessageMapperTest {
assertEquals(MessageRole.USER, roundTripped.role) assertEquals(MessageRole.USER, roundTripped.role)
} }
@Test
fun roundTripPreservesLocalPendingProvenance() {
val roundTripped =
ChatMessage(
id = "pending",
role = MessageRole.USER,
content = "not submitted",
messageProvenance = MessageProvenance.LOCAL_PENDING,
).toEntity("s").toUiModel()
assertEquals(MessageProvenance.LOCAL_PENDING, roundTripped.messageProvenance)
}
@Test
fun unknownPersistedProvenanceDoesNotInventDeliveryEvidence() {
val entity =
ChatMessageEntity(
id = "legacy",
sessionId = "s",
role = "USER",
content = "ambiguous",
timestamp = 1L,
messageProvenance = "future-value",
)
assertEquals(MessageProvenance.UNKNOWN, entity.toUiModel().messageProvenance)
}
@Test @Test
fun roundTripWithoutDisplayKindStaysNull() { fun roundTripWithoutDisplayKindStaysNull() {
val ui = val ui =
@@ -97,4 +97,16 @@ class HermesDatabaseMigrationsTest {
) )
} }
} }
@Test
fun migration9to10_addsDurableProvenanceWithoutInventingDeliveryState() =
runBlocking {
val connection = mockk<SQLiteConnection>(relaxed = true)
HermesDatabase.MIGRATION_9_10.migrate(connection)
verify(exactly = 1) {
connection.execSQL(
"ALTER TABLE chat_messages ADD COLUMN message_provenance TEXT NOT NULL DEFAULT 'UNKNOWN'",
)
}
}
} }
@@ -2,8 +2,10 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.MockWebServer
@@ -59,6 +61,7 @@ class ProfileScopeInterceptorTest {
server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
val builder = Request.Builder().url(server.url(path)) val builder = Request.Builder().url(server.url(path))
when (method) { when (method) {
"POST" -> builder.post("{}".toRequestBody("application/json".toMediaType()))
"DELETE" -> builder.delete() "DELETE" -> builder.delete()
else -> builder.get() else -> builder.get()
} }
@@ -269,4 +272,27 @@ class ProfileScopeInterceptorTest {
assertEquals("work", url.queryParameter("profile")) assertEquals("work", url.queryParameter("profile"))
assertEquals("/api/status/health", url.encodedPath) 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"))
}
} }
@@ -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<HermesApiService>()
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<AudioTranscriptionRequest>()
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<HermesApiService>()
val secondApi = mockk<HermesApiService>()
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<Long>()
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()) }
}
}
@@ -1,9 +1,13 @@
package com.m57.hermescontrol.notification package com.m57.hermescontrol.notification
import android.content.Context import android.content.Context
import com.m57.hermescontrol.data.ws.WsEvent
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import org.junit.Assert.assertEquals 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 org.junit.Test
/** /**
@@ -104,4 +108,59 @@ class ChatNotificationServiceTest {
atomicBoolean.get(), 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)
}
} }
@@ -0,0 +1,187 @@
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 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")
val old =
ChatMessage(
id = "cached-user",
role = MessageRole.USER,
content = "old",
timestamp = Long.MAX_VALUE,
attachments = listOf(attachment),
isHistoricalCache = true,
)
val pending = old.copy(id = "pending", content = "pending", isHistoricalCache = false)
val recent = ChatMessage(id = "rest-s-100", role = MessageRole.ASSISTANT, content = "recent", timestamp = 1)
val live = historicalTool.copy(id = "live-tool", toolCallId = "call-live", isHistoricalCache = false)
val resume = ChatMessage(id = "resume", role = MessageRole.SYSTEM, content = "Session resumed")
val merged = mergeTranscriptWithLive(listOf(recent), listOf(old, historicalTool, pending, live, resume))
assertEquals(listOf(old.id, historicalTool.id, recent.id, pending.id, live.id, resume.id), merged.map { it.id })
assertEquals(listOf(attachment), merged.first().attachments)
assertEquals(listOf(attachment), merged.single { it.id == pending.id }.attachments)
assertEquals(merged, mergeTranscriptWithLive(listOf(recent), merged))
}
}
@@ -12,6 +12,63 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class ChatPagingMergeTest { class ChatPagingMergeTest {
@Test
fun lateCachedRunningToolCannotReplaceCanonicalServerResult() {
val canonical =
ChatMessage(
id = "rest-s-42",
role = MessageRole.TOOL,
content = """{"output":"server result","exit_code":0}""",
toolCallId = "call-42",
toolStatus = ToolStatus.COMPLETED,
)
val cached =
ChatMessage(
id = "cached-tool",
role = MessageRole.TOOL,
content = """{"name":"terminal","args":{"command":"pwd"}}""",
toolName = "terminal",
toolCallId = "call-42",
toolStatus = ToolStatus.RUNNING,
isHistoricalCache = true,
tokenCount = 7,
tps = 2.5,
finishTimestamp = 123L,
)
val merged = mergeCachedTranscriptPage(listOf(cached), listOf(canonical)).single()
assertEquals(canonical.id, merged.id)
assertEquals(canonical.content, merged.content)
assertEquals(ToolStatus.COMPLETED, merged.toolStatus)
assertEquals("terminal", merged.toolName)
assertEquals("call-42", merged.toolCallId)
assertEquals(cached.tokenCount, merged.tokenCount)
assertEquals(cached.tps, merged.tps)
assertEquals(cached.finishTimestamp, merged.finishTimestamp)
}
@Test
fun canonicalResultSettlesHistoricalRunningToolWithServerOutput() {
val cached =
ChatMessage(
id = "old-tool",
role = MessageRole.TOOL,
content = """{"name":"terminal","args":{"command":"pwd"}}""",
toolName = "terminal",
toolCallId = "call-42",
toolStatus = ToolStatus.RUNNING,
isHistoricalCache = true,
)
val page = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(cached))
val merged = mergeTranscriptWithLive(page, listOf(cached), preserveLiveIds = true).single()
assertEquals(cached.id, merged.id)
assertEquals("rest-s-42", merged.canonicalRestId)
assertEquals(ToolStatus.COMPLETED, merged.toolStatus)
assertTrue(merged.content.contains("exit_code"))
assertTrue(!merged.isHistoricalCache)
}
@Test @Test
fun confirmedLiveOccurrenceCannotConsumeEarlierIdenticalPage() { fun confirmedLiveOccurrenceCannotConsumeEarlierIdenticalPage() {
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) { for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) {
@@ -598,6 +655,50 @@ class ChatPagingMergeTest {
assertTrue(sameLogicalMessage(canonical, live)) 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 @Test
fun permanentlyLocalRowsDoNotMatchCanonicalRowsByContent() { fun permanentlyLocalRowsDoNotMatchCanonicalRowsByContent() {
val localFeedback = val localFeedback =
@@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -106,6 +107,23 @@ class ChatPersistenceRepositoryTest {
assertEquals(1000L, entity.timestamp) 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 @Test
fun persistMessages_upsertsAllToDao() = fun persistMessages_upsertsAllToDao() =
runTest { runTest {
@@ -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))))
}
}
@@ -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)
}
}
@@ -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<ChatMessage>,
existing: List<ChatMessage>,
): List<ChatMessage> =
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<SharedPreferences>()
val editor = mockk<SharedPreferences.Editor>()
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<SharedPreferences>()
val editor = mockk<SharedPreferences.Editor>()
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)
}
}
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
import android.app.Application import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager 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.HermesDatabase
import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.model.ActionResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
@@ -76,6 +77,14 @@ class ChatUpdateCommandTest {
every { android.util.Log.e(any(), any(), any()) } returns 0 every { android.util.Log.e(any(), any(), any()) } returns 0
mockkObject(AuthManager) 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<DataScope?>(null)
mockkObject(HermesWsClient) mockkObject(HermesWsClient)
mockkObject(ApiClient) mockkObject(ApiClient)
mockkObject(HermesDatabase) mockkObject(HermesDatabase)
@@ -87,6 +96,10 @@ class ChatUpdateCommandTest {
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token" 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.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false every { AuthManager.isMessageStatsEnabled() } returns false
@@ -148,7 +161,15 @@ class ChatUpdateCommandTest {
// threads — with a relaxed-mock app that NPEs on cacheDir and leaks // threads — with a relaxed-mock app that NPEs on cacheDir and leaks
// an uncaught exception into the NEXT runTest class (CI flakes: // an uncaught exception into the NEXT runTest class (CI flakes:
// UncaughtExceptionsBeforeTest). // UncaughtExceptionsBeforeTest).
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
advanceUntilIdle() advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null)) mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -185,7 +206,15 @@ class ChatUpdateCommandTest {
@Test @Test
fun `applyUpdate triggers the REST action and starts the progress popup`() = fun `applyUpdate triggers the REST action and starts the progress popup`() =
runTest { 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 coEvery { mockApi.updateHermes() } returns
Response.success(ActionResponse(ok = true, name = "hermes-update")) Response.success(ActionResponse(ok = true, name = "hermes-update"))
// One running poll, then the action exits — runTest's teardown // One running poll, then the action exits — runTest's teardown
@@ -224,7 +253,15 @@ class ChatUpdateCommandTest {
@Test @Test
fun `applyUpdate settles on success when the action exits cleanly`() = fun `applyUpdate settles on success when the action exits cleanly`() =
runTest { 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 coEvery { mockApi.updateHermes() } returns
Response.success(ActionResponse(ok = true, name = "hermes-update")) Response.success(ActionResponse(ok = true, name = "hermes-update"))
coEvery { mockApi.getActionStatus("hermes-update") } returns coEvery { mockApi.getActionStatus("hermes-update") } returns
@@ -252,7 +289,15 @@ class ChatUpdateCommandTest {
@Test @Test
fun `applyUpdate surfaces a rejected trigger in the popup`() = fun `applyUpdate surfaces a rejected trigger in the popup`() =
runTest { 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 coEvery { mockApi.updateHermes() } returns
Response.error(404, "no update endpoint".toResponseBody()) Response.error(404, "no update endpoint".toResponseBody())
@@ -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))
}
}
File diff suppressed because it is too large. Load diff
@@ -26,6 +26,21 @@ class FullBleedTurnsTest {
private fun entries(vararg e: AgentEntry) = ChatTurn.Agent(e.toList()) private fun entries(vararg e: AgentEntry) = ChatTurn.Agent(e.toList())
@Test
fun historicalRunningToolDoesNotClaimLiveActivity() {
val messages =
listOf(
msg("user", MessageRole.USER),
msg("old-tool", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING)
.copy(toolName = "terminal", isHistoricalCache = true),
)
assertEquals(AgentStatus.Typing, deriveAgentStatus(true, StreamingState(), messages))
assertEquals(
AgentStatus.Tool("terminal"),
deriveAgentStatus(true, StreamingState(), messages.map { it.copy(isHistoricalCache = false) }),
)
}
@Test @Test
fun `empty list produces no turns`() { fun `empty list produces no turns`() {
assertEquals(emptyList<ChatTurn>(), groupIntoTurns(emptyList())) assertEquals(emptyList<ChatTurn>(), groupIntoTurns(emptyList()))
@@ -568,6 +568,64 @@ class MarkdownTextFeatureTest {
assertEquals("Run val x = 1 here", parsed.toString()) 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 @Test
fun testArabicStartingWithEnglishWord_wrapsEnglishInLtrIsolate() { fun testArabicStartingWithEnglishWord_wrapsEnglishInLtrIsolate() {
val input = "Okay سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو" val input = "Okay سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو"
@@ -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)
}
}
@@ -2,7 +2,10 @@ package com.m57.hermescontrol.ui.chat
import android.app.Application import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager 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.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.remote.ApiClient
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.ConnectionStatus
@@ -11,6 +14,7 @@ import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkObject import io.mockk.mockkObject
@@ -74,6 +78,14 @@ class SlashCommandDispatchRpcTest {
// COMMAND_DISPATCH captures in suite order). setMain alone is safe — // COMMAND_DISPATCH captures in suite order). setMain alone is safe —
// the slash-dispatch send path is synchronous (no IO hop). // the slash-dispatch send path is synchronous (no IO hop).
mockkObject(AuthManager) 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<DataScope?>(null)
mockkObject(HermesWsClient) mockkObject(HermesWsClient)
mockkObject(ApiClient) mockkObject(ApiClient)
mockkObject(HermesDatabase) mockkObject(HermesDatabase)
@@ -94,6 +106,10 @@ class SlashCommandDispatchRpcTest {
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token" 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.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false every { AuthManager.isMessageStatsEnabled() } returns false
@@ -152,7 +168,15 @@ class SlashCommandDispatchRpcTest {
} }
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> { private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
advanceUntilIdle() advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null)) mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -444,7 +468,11 @@ class SlashCommandDispatchRpcTest {
every { every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers { } 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<String>(0)
val d = CompletableDeferred<Any?>() val d = CompletableDeferred<Any?>()
if (m == WsMethods.COMMAND_DISPATCH) { if (m == WsMethods.COMMAND_DISPATCH) {
// Backend rejects /status with the registry-miss 4018 (issue #576). // Backend rejects /status with the registry-miss 4018 (issue #576).
@@ -714,7 +742,9 @@ class SlashCommandDispatchRpcTest {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers { } answers {
val d = CompletableDeferred<Any?>() val d = CompletableDeferred<Any?>()
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<String>(0) == WsMethods.COMMAND_DISPATCH) {
// Backend /init returns type:"send" with the AGENTS.md prompt. // 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")) d.complete(mapOf("type" to "send", "message" to "Scan this repo and write AGENTS.md"))
} else { } else {
@@ -730,6 +760,15 @@ class SlashCommandDispatchRpcTest {
arg<((String) -> Unit)?>(2)?.invoke(id) arg<((String) -> Unit)?>(2)?.invoke(id)
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") vm.sendMessage("/init")
advanceUntilIdle() advanceUntilIdle()
@@ -83,6 +83,10 @@ class FakeChatMessageDao : ChatMessageDao {
messages.values.removeAll { it.sessionId == sessionId } 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. */ /** Direct access for test setup — bypasses the suspend modifier. */
fun addMessageDirect(message: ChatMessageEntity) { fun addMessageDirect(message: ChatMessageEntity) {
val existing = messages[message.id] val existing = messages[message.id]
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.settings
import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager 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.remote.ApiClient
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
@@ -47,6 +48,17 @@ class SettingsViewModelTest {
return vm 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 @Before
fun setUp() { fun setUp() {
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
@@ -71,6 +83,8 @@ class SettingsViewModelTest {
every { AuthManager.isUseDynamicColors() } returns true every { AuthManager.isUseDynamicColors() } returns true
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
every { AuthManager.isTypingEffectEnabled() } returns true 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.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatFontScale() } returns 1.0f every { AuthManager.getChatFontScale() } returns 1.0f
every { AuthManager.isMessageStatsEnabled() } returns false every { AuthManager.isMessageStatsEnabled() } returns false
+2 -2
View File
@@ -14,7 +14,7 @@ coroutines = "1.11.0"
junit = "4.13.2" junit = "4.13.2"
junit5 = "6.1.3" junit5 = "6.1.3"
junitPlatform = "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 # standalone per KSP 2.0+ guidance — NOT kotlinVersion-kspVersion; easy
# to miss during Kotlin upgrades since it no longer tracks the Kotlin version. # to miss during Kotlin upgrades since it no longer tracks the Kotlin version.
ksp = "2.3.12" ksp = "2.3.12"
@@ -30,7 +30,7 @@ coil = "3.6.3"
room = "3.0.2" room = "3.0.2"
sqlcipher = "4.19.0" sqlcipher = "4.19.0"
datastore = "1.1.2" datastore = "1.1.2"
latex = "1.5.5" latex = "1.5.6"
[libraries] [libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }