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
+9284 -465

No files matched your search

+1 -1
View File
@@ -75,7 +75,7 @@ jobs:
with:
languages: ${{ matrix.language }}
build-mode: manual
tools: https://github.com/github/codeql-action/releases/download/codeql-bundle-v2.26.2/codeql-bundle-linux64.tar.gz
tools: https://github.com/github/codeql-action/releases/download/codeql-bundle-v2.27.1/codeql-bundle-linux64.tar.gz
- name: Build Codebase (Compile Only)
run: |
+11 -1
View File
@@ -36,6 +36,16 @@ android {
}
signingConfigs {
getByName("debug") {
// Stable debug keystore committed to the repo so debug APKs keep
// the same signature across machines and CI runs. CI runners use
// an ephemeral default debug keystore otherwise, which makes every
// CI debug APK uninstallable over a previously installed build.
storeFile = file("debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
create("release") {
val isReleaseBuild =
gradle.startParameter.taskNames.any { name ->
@@ -301,4 +311,4 @@ tasks.named("check") {
tasks.withType<Test> {
useJUnitPlatform()
}
}
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
.databaseBuilder(context, HermesDatabase::class.java, databaseName)
.setDriver(driver())
.addMigrations(HermesDatabase.MIGRATION_8_9)
.addMigrations(HermesDatabase.MIGRATION_8_9, HermesDatabase.MIGRATION_9_10)
.build()
repository = ChatPersistenceRepository(database.chatMessageDao())
}
@@ -234,6 +234,29 @@ class ChatMessagePagingDeviceTest {
assertPagingIndexUsed()
}
@Test
fun migration9to10PreservesAmbiguousUnsentUuidUserAsUnknown() =
runBlocking {
val unsent =
ChatMessageEntity(
id = "pending-before-upgrade",
sessionId = "session",
role = "USER",
content = "may not have reached the server",
timestamp = 1L,
sortOrder = 1L,
)
createVersion9(listOf(unsent))
openDatabase()
val restored = database.chatMessageDao().getMessage(unsent.id)!!
assertEquals("UNKNOWN", restored.messageProvenance)
assertEquals(
com.m57.hermescontrol.ui.chat.MessageProvenance.UNKNOWN,
restored.toUiModel().messageProvenance,
)
assertEquals(unsent.content, restored.content)
}
@Test
fun confirmedAliasPreservesLivePayloadAndCanonicalOrderAcrossReopen() =
runBlocking {
@@ -350,6 +373,63 @@ class ChatMessagePagingDeviceTest {
}
}
/** Build the exported v9 schema so Room validates the real non-destructive v9 -> v10 path. */
private fun createVersion9(rows: List<ChatMessageEntity>) {
database.close()
context.deleteDatabase(databaseName)
val schema =
InstrumentationRegistry
.getInstrumentation()
.context.assets
.open("com.m57.hermescontrol.data.local.HermesDatabase/9.json")
.bufferedReader()
.use { JSONObject(it.readText()).getJSONObject("database") }
val file = context.getDatabasePath(databaseName)
file.parentFile?.mkdirs()
driver().open(file.absolutePath).use { connection ->
val entity = schema.getJSONArray("entities").getJSONObject(0)
connection.execSQL(entity.getString("createSql").replace("\${TABLE_NAME}", "chat_messages"))
val indices = entity.getJSONArray("indices")
for (index in 0 until indices.length()) {
connection.execSQL(
indices.getJSONObject(index).getString("createSql").replace("\${TABLE_NAME}", "chat_messages"),
)
}
val setup = schema.getJSONArray("setupQueries")
for (index in 0 until setup.length()) connection.execSQL(setup.getString(index))
connection
.prepare(
"INSERT INTO chat_messages (id, session_id, role, content, reasoning_text, timestamp, " +
"tool_name, tool_call_id, tool_status, is_streaming, display_kind, token_count, tps, " +
"completion_id, rest_id, sort_group, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
).use { insert ->
rows.forEach { row ->
insert.bindText(1, row.id)
insert.bindText(2, row.sessionId)
insert.bindText(3, row.role)
insert.bindText(4, row.content)
insert.bindText(5, row.reasoningText)
insert.bindLong(6, row.timestamp)
row.toolName?.let { insert.bindText(7, it) } ?: insert.bindNull(7)
insert.bindText(8, row.toolCallId)
row.toolStatus?.let { insert.bindText(9, it) } ?: insert.bindNull(9)
insert.bindLong(10, if (row.isStreaming) 1L else 0L)
row.displayKind?.let { insert.bindText(11, it) } ?: insert.bindNull(11)
row.tokenCount?.let { insert.bindLong(12, it.toLong()) } ?: insert.bindNull(12)
row.tps?.let { insert.bindDouble(13, it) } ?: insert.bindNull(13)
row.completionId?.let { insert.bindText(14, it) } ?: insert.bindNull(14)
row.restId?.let { insert.bindText(15, it) } ?: insert.bindNull(15)
insert.bindLong(16, row.sortGroup.toLong())
insert.bindLong(17, row.sortOrder)
insert.step()
insert.reset()
insert.clearBindings()
}
}
connection.execSQL("PRAGMA user_version = 9")
}
}
private fun assertPagingIndexUsed() {
database.close()
driver().open(context.getDatabasePath(databaseName).absolutePath).use { connection ->
@@ -4,16 +4,19 @@ import android.content.pm.PackageManager
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.core.content.ContextCompat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.ui.common.ActionProgressController
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -125,4 +128,37 @@ class ChatScreenTest {
composeTestRule.onNodeWithTag("chat_input").assertIsDisplayed()
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
}
@Test
fun chatScreen_primarySendUsesSavedBusyDefault() {
val uiState =
ChatUiState(
connectionStatus = ConnectionStatus.CONNECTED,
isSessionReady = true,
busySendMode = BusySendMode.QUEUE,
)
val mockViewModel = mockk<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
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
@@ -12,8 +13,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -24,9 +27,12 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.test.espresso.Espresso.pressBack
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.ws.CommandCatalog
import org.junit.Assert.assertEquals
import org.junit.Rule
@@ -42,7 +48,7 @@ import org.junit.runner.RunWith
@MediumTest
class ComposerInteractionTest {
@get:Rule
val composeTestRule = createComposeRule()
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private var micTaps = 0
private var sends = 0
@@ -52,6 +58,158 @@ class ComposerInteractionTest {
private var photosTaps = 0
private var fileTaps = 0
private fun setContextualComposer(
text: String,
isMainTurnBusy: Boolean,
attachments: List<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. */
private fun setComposer(
reasoningLevel: String? = "medium",
@@ -246,7 +404,8 @@ class ComposerInteractionTest {
"short model pill should remain content-sized, width=$pillWidth toolbar=$toolbarWidth"
}
check(actionButton.right > toolbarBounds.right - 32.dp) {
"trailing action should stay near the toolbar edge, right=${actionButton.right} toolbarRight=${toolbarBounds.right}"
"trailing action should stay near the toolbar edge, " +
"right=${actionButton.right} toolbarRight=${toolbarBounds.right}"
}
}
@@ -337,11 +496,11 @@ class ComposerInteractionTest {
}
@Test
fun attachmentTray_dismissesOnBackPress() {
fun attachmentTray_dismissesOnActivityBack() {
setComposer()
openAttachmentTray()
pressBack()
composeTestRule.runOnUiThread { composeTestRule.activity.onBackPressedDispatcher.onBackPressed() }
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
@@ -34,6 +34,7 @@ class ComposerReadinessTest {
onMicTap = {},
isListening = false,
isAgentTyping = true,
canInterrupt = ready.value,
isConnected = true,
commandCatalog = CommandCatalog(),
isSessionReady = ready.value,
@@ -42,13 +43,20 @@ class ComposerReadinessTest {
compose.onNodeWithTag("chat_input").performTextInput("held draft")
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
compose.onNodeWithTag("chat_session_preparing").assertExists()
// Nothing to interrupt while the session prepares: the action button
// must not offer Stop (review, PR #1250).
compose.onNodeWithTag("stop_button").assertDoesNotExist()
compose.onNodeWithTag("send_button").assertIsNotEnabled().performClick()
compose.runOnIdle { assertEquals(0, sends) }
compose.runOnIdle { ready.value = true }
compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist()
// Ready + typing = an interruptible generation: Stop replaces the
// action glyph while queue-send stays available in the flat slot.
compose.onNodeWithTag("stop_button").assertExists()
compose.onNodeWithTag("send_button").assertIsEnabled().performClick()
compose.runOnIdle { assertEquals(1, sends) }
compose.runOnIdle { ready.value = false }
compose.onNodeWithTag("stop_button").assertDoesNotExist()
compose.onNodeWithTag("send_button").assertIsNotEnabled()
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
}
@@ -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.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
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.BusySendMode
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
@@ -20,6 +30,25 @@ class ChatSectionTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun busySendDefaultDropdownRoutesSelection() {
var chosen: BusySendMode? = null
composeTestRule.setContent {
ChatSection(
typingEffectEnabled = false,
onTypingEffectEnabledChange = {},
typingEffectDelayMs = 30,
onTypingEffectDelayMsChange = {},
busySendMode = BusySendMode.CORRECT,
onBusySendModeChange = { chosen = it },
)
}
composeTestRule.onNodeWithTag("busy_send_default").performClick()
val queueLabel = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.chat_busy_queue)
composeTestRule.onNodeWithText(queueLabel).performClick()
assertEquals(BusySendMode.QUEUE, chosen)
}
@Test
fun messageStatsMasterOff_keepsChildrenVisibleCheckedAndDisabled() {
composeTestRule.setContent {
@@ -48,31 +77,47 @@ class ChatSectionTest {
var assistantChanges = 0
var tpsChanges = 0
composeTestRule.setContent {
ChatSection(
typingEffectEnabled = false,
onTypingEffectEnabledChange = {},
typingEffectDelayMs = 30,
onTypingEffectDelayMsChange = {},
messageStatsEnabled = true,
onMessageStatsEnabledChange = { masterChanges++ },
showUserMessageTokens = true,
onUserMessageTokensChange = { userChanges++ },
showAssistantMessageTokens = true,
onAssistantMessageTokensChange = { assistantChanges++ },
showTokensPerSecond = true,
onTokensPerSecondChange = { tpsChanges++ },
)
// PR #1254: match the real settings page's scrollable viewport.
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
ChatSection(
typingEffectEnabled = false,
onTypingEffectEnabledChange = {},
typingEffectDelayMs = 30,
onTypingEffectDelayMsChange = {},
messageStatsEnabled = true,
onMessageStatsEnabledChange = { masterChanges++ },
showUserMessageTokens = true,
onUserMessageTokensChange = { userChanges++ },
showAssistantMessageTokens = true,
onAssistantMessageTokensChange = { assistantChanges++ },
showTokensPerSecond = true,
onTokensPerSecondChange = { tpsChanges++ },
)
}
}
composeTestRule.onNodeWithTag("settings_user_message_tokens").assertIsEnabled().performClick()
composeTestRule.onNodeWithTag("settings_assistant_message_tokens").assertIsEnabled().performClick()
composeTestRule.onNodeWithTag("settings_tps").assertIsEnabled().performClick()
composeTestRule
.onNodeWithTag("settings_user_message_tokens")
.performScrollTo()
.assertIsEnabled()
.performClick()
composeTestRule
.onNodeWithTag(
"settings_assistant_message_tokens",
).performScrollTo()
.assertIsEnabled()
.performClick()
composeTestRule
.onNodeWithTag("settings_tps")
.performScrollTo()
.assertIsEnabled()
.performClick()
assertEquals(0, masterChanges)
assertEquals(1, userChanges)
assertEquals(1, assistantChanges)
assertEquals(1, tpsChanges)
composeTestRule.onNodeWithTag("settings_message_stats").performClick()
composeTestRule.onNodeWithTag("settings_message_stats").performScrollTo().performClick()
assertEquals(1, masterChanges)
assertEquals(1, userChanges)
assertEquals(1, assistantChanges)
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.config
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
@@ -22,6 +23,7 @@ data class ServerStoreState(
val pinnedModels: List<PinnedModel> = emptyList(),
val wsAuthParam: String = "token",
val typingEffectEnabled: Boolean = true,
val busySendMode: BusySendMode = BusySendMode.CORRECT,
val typingEffectDelayMs: Int = 30,
val chatFontScale: Float = 1.0f,
val messageStatsEnabled: Boolean = false,
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.config.ServerUrlMigration
import com.m57.hermescontrol.data.config.resolvedBaseUrl
import com.m57.hermescontrol.data.config.resolvedHost
import com.m57.hermescontrol.data.config.resolvedPort
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.CookieManager
@@ -805,6 +806,12 @@ object AuthManager {
fun isTypingEffectEnabled(): Boolean = serverStore.getLatestState().typingEffectEnabled
fun getBusySendMode(): BusySendMode = serverStore.getLatestState().busySendMode
fun setBusySendMode(mode: BusySendMode) {
serverStore.update { it.copy(busySendMode = mode) }
}
fun setTypingEffectEnabled(enabled: Boolean) {
serverStore.update { it.copy(typingEffectEnabled = enabled) }
}
@@ -86,4 +86,7 @@ interface ChatMessageDao {
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
suspend fun deleteMessagesForSession(sessionId: String)
@Query("DELETE FROM chat_messages WHERE id = :id AND rest_id IS NULL")
suspend fun deleteUnconfirmedMessage(id: String)
}
@@ -54,6 +54,8 @@ data class ChatMessageEntity(
val sortGroup: Int = 1,
@ColumnInfo(name = "sort_order", defaultValue = "0")
val sortOrder: Long = 0,
@ColumnInfo(name = "message_provenance", defaultValue = "'UNKNOWN'")
val messageProvenance: String = "UNKNOWN",
)
internal fun ChatMessageEntity.isSessionStartMarker(): Boolean =
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.data.local
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageProvenance
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.ToolStatus
@@ -40,6 +41,9 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
completionId = completionId,
restId = restId,
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 && !isSessionStartMarker() },
messageProvenance =
MessageProvenance.entries.firstOrNull { it.name == messageProvenance }
?: MessageProvenance.UNKNOWN,
)
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
@@ -59,4 +63,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
tps = tps,
completionId = completionId,
restId = restId,
messageProvenance = messageProvenance.name,
)
@@ -14,7 +14,7 @@ import java.io.File
@Database(
entities = [ChatMessageEntity::class],
version = 9,
version = 10,
exportSchema = true,
)
abstract class HermesDatabase : RoomDatabase() {
@@ -115,6 +115,17 @@ abstract class HermesDatabase : RoomDatabase() {
}
}
val MIGRATION_9_10: Migration =
object : Migration(9, 10) {
override suspend fun migrate(connection: SQLiteConnection) {
// v9 cannot prove whether a UUID-only row reached the server. Preserve that
// ambiguity; only new outgoing prompts record LOCAL_PENDING before submit.
connection.execSQL(
"ALTER TABLE chat_messages ADD COLUMN message_provenance TEXT NOT NULL DEFAULT 'UNKNOWN'",
)
}
}
suspend fun get(context: Context): HermesDatabase =
withContext(Dispatchers.IO) {
instance?.let { return@withContext it }
@@ -152,6 +163,7 @@ abstract class HermesDatabase : RoomDatabase() {
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
).fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
@@ -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 okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
/**
* Provides a Retrofit-backed [HermesApiService].
@@ -22,6 +24,9 @@ object ApiClient {
@Volatile
private var service: HermesApiService? = null
@Volatile
private var okHttpClient: OkHttpClient? = null
/** The current [HermesApiService] instance. Lazily created on first access. */
val hermesApi: HermesApiService
get() {
@@ -39,6 +44,7 @@ object ApiClient {
synchronized(this) {
retrofit = null
service = null
okHttpClient = null
}
}
@@ -81,6 +87,64 @@ object ApiClient {
return tempRetrofit.create(HermesApiService::class.java)
}
/**
* Dedicated [HermesApiService] for the blocking voice-note transcription
* POST (`/api/audio/transcribe`).
*
* STT blocks until the profile's provider finishes, so it cannot run on
* the shared 30s client, and a re-submitted POST would repeat expensive
* provider work after the request may already have reached the server
* (the repository also passes `retries = 0`). The returned service reuses
* the shared connection pool and the auth/profile interceptor stack, and
* applies [readTimeoutMs] to reads and writes with transport retries
* disabled (review, PR #1250).
*/
fun transcriptionService(readTimeoutMs: Long): HermesApiService {
val client =
OkHttpProvider
.base
.newBuilder()
.addInterceptor(restAuthInterceptor())
.addInterceptor(ProfileScopeInterceptor)
.authenticator(TokenRefreshAuthenticator)
.readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
.writeTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
.retryOnConnectionFailure(false)
.build()
return Retrofit
.Builder()
.baseUrl(AuthManager.endpointForBuild().baseUrl)
.client(client)
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build()
.create(HermesApiService::class.java)
}
/**
* REST auth stamping shared by every client: token mode sends the bearer
* header; gated (basic-auth cookie) mode leaves the request alone because
* the shared CookieManager jar carries the session cookie and a stray
* Authorization header would 401 (issue #470).
*/
private fun restAuthInterceptor(): Interceptor =
Interceptor { chain ->
val request = chain.request()
if (AuthManager.isGatedMode()) {
return@Interceptor chain.proceed(request)
}
val token = AuthManager.getToken()
if (!token.isNullOrBlank()) {
chain.proceed(
request
.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build(),
)
} else {
chain.proceed(request)
}
}
// ── Internal ─────────────────────────────────────────────────────────
private fun buildService(): HermesApiService =
@@ -111,25 +175,7 @@ object ApiClient {
// must NOT stamp one here — otherwise every REST tab (skills, cron,
// config, ...) fails with "token expired" while the WS chat, which
// authenticates via ?ticket=, keeps working.
val authInterceptor =
Interceptor { chain ->
val request = chain.request()
if (AuthManager.isGatedMode()) {
// Cookie in the shared jar is the only valid REST credential.
return@Interceptor chain.proceed(request)
}
val token = AuthManager.getToken()
if (!token.isNullOrBlank()) {
chain.proceed(
request
.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build(),
)
} else {
chain.proceed(request)
}
}
val authInterceptor = restAuthInterceptor()
val okHttp =
OkHttpProvider
@@ -7,6 +7,8 @@ import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.AddMcpServerRequest
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.AnalyticsResponse
import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
import com.m57.hermescontrol.data.model.BackupTriggerRequest
import com.m57.hermescontrol.data.model.BulkDeleteRequest
@@ -1030,4 +1032,14 @@ interface HermesApiService : KanbanApiService {
suspend fun deleteManagedFile(
@Body body: ManagedFileDelete,
): Response<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 =
listOf(
"/api/analytics",
// Voice-note transcription resolves STT through the profile's
// configured provider, so the request must carry the active
// profile (the desktop sends this route `...profileScoped()`;
// hermes_cli/web_routers/audio.py is config-scoped). Unscoped, a
// multi-profile host transcribes under the launch profile
// (review, PR #1250).
"/api/audio",
"/api/config",
"/api/credentials/pool",
"/api/cron",
@@ -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 deferred: CompletableDeferred<Any?>,
var timeoutJob: Job? = null,
val suppressErrorEvent: Boolean = false,
)
/** Tracks in-flight [request] calls by their JSON-RPC id. */
@@ -794,11 +795,12 @@ object HermesWsClient {
method: String,
params: Map<String, Any> = emptyMap(),
timeoutMs: Long = REQUEST_TIMEOUT_MS,
suppressErrorEvent: Boolean = false,
): CompletableDeferred<Any?> {
val deferred = CompletableDeferred<Any?>()
val id =
send(method, params) { reqId ->
pendingCalls[reqId] = PendingCall(method, deferred)
pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent)
}
deferred.invokeOnCompletion { cause ->
if (cause is CancellationException) {
@@ -1458,6 +1460,16 @@ object HermesWsClient {
disconnectIfIdleInBackground()
}
}
if (pendingCalls[event.id]?.suppressErrorEvent == true) {
// Opt-in suppression: the caller already handles the
// failure through the CompletableDeferred, so the event
// copy would only surface a duplicate UI banner for
// optional features (e.g. "subagent.list" on gateways
// without the method, issue #1089). Default request()
// behavior still emits the event for shared consumers.
resolvePending(event.id, null, event.error)
return
}
removeQueuedMessage(event.id)
resolvePending(event.id, null, event.error)
}
@@ -37,6 +37,7 @@ object SubagentRepository {
.request(
WsMethods.SUBAGENT_LIST,
mapOf("session_id" to sessionId),
suppressErrorEvent = true,
).await()
decode<SubagentListResponse>(result)
} catch (e: Exception) {
@@ -65,7 +66,13 @@ object SubagentRepository {
if (sessionId.isNotBlank()) {
params["session_id"] = sessionId
}
val result = HermesWsClient.request(WsMethods.SUBAGENT_TAIL, params).await()
val result =
HermesWsClient
.request(
WsMethods.SUBAGENT_TAIL,
params,
suppressErrorEvent = true,
).await()
return decode<SubagentTailResponse>(result)
}
@@ -11,6 +11,7 @@ object WsMethods {
const val SESSION_CREATE = "session.create"
const val SESSION_INTERRUPT = "session.interrupt"
const val SESSION_REDIRECT = "session.redirect"
const val SESSION_STEER = "session.steer"
const val SESSION_DELETE = "session.delete"
const val SESSION_TITLE = "session.title"
const val SESSION_BRANCH = "session.branch"
@@ -17,6 +17,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.ui.chat.replyFailureFromPayload
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -27,6 +28,46 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
internal data class MessageCompleteNotificationPlan(
val text: String,
val sessionId: String?,
val isReplyMessage: Boolean,
val completionId: String?,
val correlationText: String?,
val allowInlineReply: Boolean,
)
internal fun messageCompleteNotificationPlan(
event: WsEvent.MessageComplete,
targetSessionId: String?,
newMessageText: String,
failureText: String,
): MessageCompleteNotificationPlan {
if (replyFailureFromPayload(event.rawPayload, fallback = "") != null) {
return MessageCompleteNotificationPlan(
text = failureText,
sessionId = targetSessionId,
isReplyMessage = false,
completionId = null,
correlationText = null,
allowInlineReply = false,
)
}
return MessageCompleteNotificationPlan(
text =
event.text
.take(100)
.replace("\n", " ")
.ifBlank { newMessageText },
sessionId = targetSessionId,
isReplyMessage = true,
completionId = event.completionId,
correlationText = event.text,
allowInlineReply = true,
)
}
/**
* Foreground service that keeps the WebSocket connection alive while the app
* is backgrounded and posts a notification when a new assistant reply
@@ -111,19 +152,22 @@ class ChatNotificationService : Service() {
if (!isAppInForeground.get()) {
when (event) {
is WsEvent.MessageComplete -> {
val preview =
event.text
.take(100)
.replace("\n", " ")
.ifBlank { getString(R.string.notif_new_message) }
val targetSessionId =
event.storedSessionId
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
val plan =
messageCompleteNotificationPlan(
event = event,
targetSessionId = targetSessionId,
newMessageText = getString(R.string.notif_new_message),
failureText = getString(R.string.chat_reply_failed_title),
)
showReplyNotification(
text = preview,
sessionId = targetSessionId,
isReplyMessage = true,
completionId = event.completionId,
text = plan.text,
sessionId = plan.sessionId,
isReplyMessage = plan.isReplyMessage,
completionId = plan.completionId,
allowInlineReply = plan.allowInlineReply,
// The durable REST row for this turn, when the
// boundary armed before the prompt was submitted
// still lets us name it unambiguously. Null is a
@@ -132,7 +176,9 @@ class ChatNotificationService : Service() {
// is strictly better than dismissing the wrong
// duplicate reply.
serverMessageId =
coalesceTurnRow(targetSessionId, event.text),
plan.correlationText?.let {
coalesceTurnRow(plan.sessionId, it)
},
)
// The wait is over — retire the foreground
// service. The reply notification above
@@ -216,6 +262,7 @@ class ChatNotificationService : Service() {
isReplyMessage: Boolean = false,
completionId: String? = null,
serverMessageId: Int? = null,
allowInlineReply: Boolean = true,
) {
val builder =
NotificationCompat
@@ -262,7 +309,7 @@ class ChatNotificationService : Service() {
)
}
if (!sessionId.isNullOrBlank()) {
if (allowInlineReply && !sessionId.isNullOrBlank()) {
val replyLabel = getString(R.string.notif_reply_placeholder)
val remoteInput =
RemoteInput
@@ -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,
)
/** Durable evidence about a locally-created row; UNKNOWN must not be treated as proof of delivery. */
enum class MessageProvenance {
UNKNOWN,
LOCAL_PENDING,
}
data class ChatMessage(
val id: String = UUID.randomUUID().toString(),
val role: MessageRole,
@@ -91,12 +97,26 @@ data class ChatMessage(
val restId: String? = null,
/** Cache insertion sequence for unconfirmed local rows; null before first persistence. */
val localOrder: Long? = null,
/** Persisted before prompt submission so process death cannot turn an unsent prompt into old history. */
val messageProvenance: MessageProvenance = MessageProvenance.UNKNOWN,
/** Read from history, not observed live in this view. Never persisted as delivery state. */
val isHistoricalCache: Boolean = false,
/**
* 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. */
internal val ChatMessage.canonicalRestId: String?
get() = restId ?: id.takeIf { it.startsWith("rest-") }
/** A persisted RUNNING snapshot is not evidence of current tool activity. */
internal val ChatMessage.isToolRunning: Boolean
get() = toolStatus == ToolStatus.RUNNING && !isHistoricalCache
/**
* Single live transcript log entry for subagent execution.
*/
@@ -84,4 +84,8 @@ open class ChatPersistenceRepository(
suspend fun clearMessagesForSession(sessionId: String) {
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
@@ -76,12 +78,15 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.HistoryScreen
import com.m57.hermescontrol.LogsScreen
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.reasoningSupport
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
@@ -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.ReactionHeartsOverlay
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
@@ -167,7 +173,11 @@ fun ChatScreen(
// Snapshot-backed search state — read directly so only the scopes that
// read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState
val displayedMessages = timelineState.historyMessages ?: state.messages
val sourceMessages = timelineState.historyMessages ?: state.messages
val displayedMessages =
remember(sourceMessages, state.pendingSends) {
messagesWithoutUnsentQueue(sourceMessages, state.pendingSends)
}
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
@@ -190,6 +200,7 @@ fun ChatScreen(
}
Lifecycle.Event.ON_RESUME -> {
viewModel.refreshSettings()
connectorsViewModel.onResume()
val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted
val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted }
@@ -309,7 +320,7 @@ fun ChatScreen(
// streaming tail.
LaunchedEffect(
timelineState.isHistorical,
state.messages,
displayedMessages,
streamingState.streamingMessage,
streamingState.isThinking,
state.subagentIndicators,
@@ -323,13 +334,13 @@ fun ChatScreen(
scrollController.onTailChanged(
tailKey =
tailContentKey(
messages = state.messages,
messages = displayedMessages,
streamingMessage = streamingState.streamingMessage,
isThinking = streamingState.isThinking,
subagentIndicators = state.subagentIndicators,
clarifyRequest = state.clarifyRequest,
),
messageCount = state.messages.size,
messageCount = displayedMessages.size,
)
}
LaunchedEffect(timelineState.historyAnchorRowId) {
@@ -429,12 +440,16 @@ fun ChatScreen(
onAddAttachments = { attachments ->
viewModel.addAttachments(attachments)
},
onVoiceNoteRecorded = { file ->
viewModel.sendVoiceNote(file)
},
onShowMessage = { msg ->
scrollScope.launch {
snackbarHostState.showSnackbar(msg)
}
},
launchExternalActivity = launchExternalActivity,
isTranscribingVoiceNote = state.isTranscribingVoiceNote,
context = context,
)
@@ -784,6 +799,39 @@ fun ChatScreen(
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
openingAttachmentPath = state.openingAttachmentPath,
onImageClick = { viewingImage = it },
replyErrorContent =
state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure ->
{
val clipboard = LocalClipboardManager.current
val copiedMessage = stringResource(R.string.chat_reply_failed_copied)
val shareTitle = stringResource(R.string.chat_reply_failed_share)
val shareUnavailable = stringResource(R.string.chat_reply_failed_share_unavailable)
ReplyErrorCard(
failure = failure,
onDismiss = { viewModel.dismissReplyFailure(failure.id) },
onOpenLogs = { NavigationController.navigateTo(LogsScreen) },
onCopy = { details ->
clipboard.setText(AnnotatedString(details))
scrollScope.launch { snackbarHostState.showSnackbar(copiedMessage) }
},
onShare = { details ->
val report = "Hermes Mobile ${BuildConfig.VERSION_NAME}\n\n$details"
val intent =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, report)
}
try {
launchExternalActivity {
context.startActivity(Intent.createChooser(intent, shareTitle))
}
} catch (_: ActivityNotFoundException) {
scrollScope.launch { snackbarHostState.showSnackbar(shareUnavailable) }
}
},
)
}
},
)
// Loading overlay
@@ -834,6 +882,12 @@ fun ChatScreen(
},
)
com.m57.hermescontrol.ui.chat.components.PendingSendPanel(
sends = state.pendingSends,
mainTurnBusy = state.isMainTurnBusy,
onSendNow = viewModel::sendQueuedNow,
)
ChatInputBar(
inputFieldValue = inputFieldValue,
onInputChange = { inputFieldValue = it },
@@ -844,9 +898,23 @@ fun ChatScreen(
scrollController.jumpToBottom(animated = true)
}
},
onBusySend = { mode ->
if (viewModel.sendMessage(inputFieldValue.text, mode)) {
inputFieldValue = TextFieldValue("")
scrollController.jumpToBottom(animated = true)
}
},
onMicTap = mediaLaunchers.onMicTap,
isListening = mediaLaunchers.isListening,
onMicHoldStart = mediaLaunchers.onMicHoldStart,
onMicHoldEnd = mediaLaunchers.onMicHoldEnd,
onMicHoldCancel = mediaLaunchers.onMicHoldCancel,
isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote,
isRecordingVoice = mediaLaunchers.isRecordingVoice,
voiceNoteAmplitude = mediaLaunchers.voiceNoteAmplitude,
onStopGeneration = { viewModel.interruptSession() },
isAgentTyping = state.isAgentTyping,
isMainTurnBusy = state.isMainTurnBusy,
canInterrupt = state.canInterrupt,
isConnected = state.isConnected,
isSessionReady = state.isSessionReady && !timelineState.isHistorical,
sessionPreparationFailed = state.resumeError != null,
@@ -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 verifierContentByRestIndex = mutableMapOf<Int, String>()
if (!isPagingOlder) {
// #129: reserve durable identity before matching any repeated live prose.
// REST-only recovery remains fail-closed when the target row is absent.
@@ -89,6 +90,27 @@ internal fun mapServerMessages(
remainingWs.removeAt(wsIdx).completionId?.let { wsCompletionIdByRestIndex[i] = it }
}
}
// Fallback: match live assistant containing a file-mutation verifier footer against
// its footer-free REST counterpart (issue #1241).
for (i in messages.indices.reversed()) {
if (remainingWs.isEmpty()) break
if (i in wsCompletionIdByRestIndex || restIdAt(i) in reservedRestIds) continue
val m = messages[i]
if (m.role?.lowercase() in listOf("user", "system", "tool")) continue
val rawContent = m.displayContentText ?: m.contentText
if (rawContent.isBlank()) continue
val canonicalContent =
if (rawContent.contains("MEDIA:")) HostMediaExtractor.strip(rawContent).trim() else rawContent.trim()
val wsIdx = remainingWs.indexOfLast { ChatVerifierFooter.matchesBase(it.content, canonicalContent) }
if (wsIdx >= 0) {
val matchedWs = remainingWs.removeAt(wsIdx)
matchedWs.completionId?.let { wsCompletionIdByRestIndex[i] = it }
// Propagate the richer live footer content to the REST row so canonical mapping keeps it
if (ChatVerifierFooter.split(matchedWs.content) != null) {
verifierContentByRestIndex[i] = matchedWs.content
}
}
}
}
// Reasoning follows the same reserved identities as completions, never a reusable text lookup.
@@ -119,6 +141,19 @@ internal fun mapServerMessages(
val match = remaining.indexOfLast { it.content.trim() == content }
if (match >= 0) reasoningSources[index] = remaining.removeAt(match)
}
for (index in messages.indices.reversed()) {
val row = messages[index]
val displayContent = row.displayContentText ?: row.contentText
if (reasoningSources[index] != null || displayContent.isBlank() ||
row.role?.lowercase() in listOf("user", "system", "tool") ||
liveByExactId[restIdAt(index)]?.completionId != null || index in wsCompletionIdByRestIndex
) {
continue
}
val content = HostMediaExtractor.strip(displayContent).trim()
val match = remaining.indexOfLast { ChatVerifierFooter.matchesBase(it.content, content) }
if (match >= 0) reasoningSources[index] = remaining.removeAt(match)
}
}
val mapped = mutableListOf<ChatMessage>()
@@ -166,6 +201,8 @@ internal fun mapServerMessages(
var finalContent =
if (role == MessageRole.USER) {
stripGatewayAttachedContext(stripGatewaySteerWrapper(rawContent))
} else if (role == MessageRole.ASSISTANT && verifierContentByRestIndex.containsKey(index)) {
verifierContentByRestIndex[index] ?: rawContent
} else {
rawContent
}
@@ -227,6 +264,17 @@ internal fun mapServerMessages(
val matches = matchTranscriptMessages(mappedTools, liveTools)
val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] }
return mapped.map { message ->
toolsById[message.id]?.copy(restId = message.canonicalRestId) ?: message
val local = toolsById[message.id]
if (local?.isHistoricalCache == true && local.toolStatus == ToolStatus.RUNNING) {
// A canonical tool-result row settles a cached tool.start, not the reverse.
local.copy(
restId = message.canonicalRestId,
content = message.content,
toolStatus = ToolStatus.COMPLETED,
isHistoricalCache = false,
)
} else {
local?.copy(restId = message.canonicalRestId) ?: message
}
}
}
@@ -185,6 +185,14 @@ internal class TranscriptComparison(
val tb = trimmed.getOrPut(b.content) { b.content.trim() }
if (a.role == MessageRole.USER && (ta.startsWith("/") || tb.startsWith("/"))) return false
if (ta == tb) return true
if (a.role == MessageRole.ASSISTANT &&
(a.completionId != null || b.completionId != null) &&
a.completionId == b.completionId &&
ta.isNotBlank() && tb.isNotBlank() &&
ChatVerifierFooter.matchesBase(ta, tb)
) {
return true
}
if (a.role == MessageRole.USER &&
captions.getOrPut(ta) { stripAttachmentRefLines(ta) } ==
captions.getOrPut(tb) { stripAttachmentRefLines(tb) }
@@ -250,7 +258,17 @@ internal fun matchTranscriptMessages(
) &&
// Distinct IDs within the same source are separate occurrences, not echoes.
((message.canonicalRestId == null) != (other.canonicalRestId == null)) &&
comparison.same(message, other)
(
comparison.same(message, other) ||
(
allowAssistantContentMatches &&
message.role == MessageRole.ASSISTANT &&
message.content.isNotBlank() && other.content.isNotBlank() &&
message.completionId != null &&
message.completionId == other.completionId &&
ChatVerifierFooter.matchesBase(message.content, other.content)
)
)
}
if (match != null) {
used[match] = true
@@ -317,6 +335,7 @@ internal fun dedupeCachedMessages(
restId = it.canonicalRestId,
completionId = message.completionId ?: it.completionId,
displayKind = message.displayKind ?: it.displayKind,
isRestoredUnconfirmed = false,
)
} ?: message
}
@@ -341,13 +360,53 @@ internal fun mergeCachedTranscriptPage(
incoming
.mapIndexedNotNull { index, message ->
val match = matches[index] ?: return@mapIndexedNotNull null
val rich = if (match.id.startsWith("rest-") && !message.id.startsWith("rest-")) message else match
val 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
rich.copy(
id = match.id,
restId = message.canonicalRestId ?: match.canonicalRestId,
content = preservedContent ?: rich.content,
restId = match.canonicalRestId ?: message.canonicalRestId,
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()
val resolvedOrders =
@@ -381,13 +440,24 @@ internal fun mergeTranscriptWithLive(
match.copy(
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
displayKind = message.displayKind ?: match.displayKind,
isHistoricalCache = false,
isRestoredUnconfirmed = false,
)
}
preserveLiveIds && match != null && !match.id.startsWith("rest-") -> {
val mergedContent =
if (match.role == MessageRole.ASSISTANT &&
ChatVerifierFooter.split(match.content) != null &&
ChatVerifierFooter.matchesBase(match.content, message.content)
) {
match.content
} else {
message.content
}
match.copy(
restId = message.canonicalRestId ?: match.canonicalRestId,
content = message.content,
content = mergedContent,
timestamp = message.timestamp,
isStreaming = message.isStreaming,
reasoningText = message.reasoningText.ifBlank { match.reasoningText },
@@ -398,11 +468,28 @@ internal fun mergeTranscriptWithLive(
displayKind = message.displayKind ?: match.displayKind,
tokenCount = message.tokenCount ?: match.tokenCount,
completionId = message.completionId ?: match.completionId,
isHistoricalCache = false,
isRestoredUnconfirmed = false,
)
}
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>,
): List<ChatMessage> {
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
// Session-start markers use -1 and must remain before unresolved cached history.
val beforeCanonical = (mapNotNull { it.canonicalOrder }.filter { it >= 0L }.minOrNull() ?: 0L) - 1L
var precedingCanonical: Long? = null
var hasPendingPredecessor = false
var pendingLocalOrder: Long? = null
@@ -448,9 +537,18 @@ private fun List<ChatMessage>.inTranscriptOrder(
Long.MAX_VALUE
} else {
precedingCanonical?.takeIf { it >= 0L }
?: latestCanonical
?: if (message.role == MessageRole.USER) {
precedingCanonical ?: latestCanonical
} else {
latestCanonical
}
}
if (hasPendingPredecessor) pendingOrderByLocal[message.id] = pendingLocalOrder ?: Long.MAX_VALUE
} else if (message.isHistoricalCache || message.isRestoredUnconfirmed) {
// 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 {
hasPendingPredecessor = true
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.
*/
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(
state: ChatUiState,
streamingState: StreamingState,
@@ -239,6 +273,18 @@ object ChatWsEventReducer {
streamingState: StreamingState,
event: WsEvent.MessageStart,
): ReducerResult {
val previousFailureMessageId = state.replyFailureProjection?.messageId
val turnState =
state.copy(
messages =
if (previousFailureMessageId == null) {
state.messages
} else {
state.messages.filterNot { it.id == previousFailureMessageId }
},
replyFailure = null,
replyFailureProjection = null,
)
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
@@ -252,10 +298,10 @@ object ChatWsEventReducer {
val effects = mutableListOf<ReducerEffect>()
val cleanedSubagents =
if (state.subagentIndicators.all { it.isComplete || it.isFailed }) {
if (turnState.subagentIndicators.all { it.isComplete || it.isFailed }) {
emptyList()
} else {
state.subagentIndicators
turnState.subagentIndicators
}
// Build new state: finalize any orphan streaming message, then set the new one
@@ -270,13 +316,13 @@ object ChatWsEventReducer {
tps = null,
)
orphan = finalized
state.copy(
messages = state.messages.upsertById(finalized),
turnState.copy(
messages = turnState.messages.upsertById(finalized),
isAgentTyping = true,
subagentIndicators = cleanedSubagents,
)
} else {
state.copy(
turnState.copy(
isAgentTyping = true,
subagentIndicators = cleanedSubagents,
)
@@ -406,6 +452,8 @@ object ChatWsEventReducer {
streamingState: StreamingState,
event: WsEvent.MessageComplete,
): ReducerResult {
val failure = replyFailureFromPayload(event.rawPayload, event.text)
if (failure != null) return onReplyFailure(state, streamingState, event, failure)
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
@@ -520,6 +568,86 @@ object ChatWsEventReducer {
)
}
// Keep terminal diagnostics out of normal assistant messages and persistence.
private fun onReplyFailure(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.MessageComplete,
failure: ReplyFailure,
): ReducerResult {
val usageState = event.rawPayload?.let(::parseUsageSnapshot)?.let { applyUsageSnapshot(state, it) } ?: state
val streaming = streamingState.streamingMessage
val text =
if (event.rawPayload?.get("partial") == true) {
event.text
.takeIf { it.isNotBlank() }
?.let { stripSealedOrphanPrefix(it, state.messages, streamingState.sealedOrphanIds) }
?: streaming?.content.orEmpty()
} else {
streaming?.content.orEmpty()
}
val reasoning = streamingState.reasoningText.ifBlank { streaming?.reasoningText.orEmpty() }
val existingProjection = state.replyFailureProjection
val completionId = event.completionId?.takeIf { it.isNotBlank() } ?: existingProjection?.completionId
val sessionIdentity = state.currentSessionId ?: event.sessionId.orEmpty()
val failureId =
existingProjection?.failureId
?: event.completionId?.takeIf { it.isNotBlank() }?.let { "reply-failure-$sessionIdentity-$it" }
?: failure.id
val projectionMessageId =
existingProjection?.messageId
?: streaming?.id
?: event.completionId?.takeIf { it.isNotBlank() }?.let { "reply-failure-message-$it" }
?: if (text.isNotBlank() || reasoning.isNotBlank()) "reply-failure-message-$failureId" else null
val projection = ReplyFailureProjection(projectionMessageId, failureId, completionId)
val existingProjectedMessage =
projectionMessageId?.let { id -> usageState.messages.firstOrNull { it.id == id } }
val partial =
if (text.isNotBlank() || reasoning.isNotBlank()) {
val base =
streaming
?: existingProjectedMessage
?: ChatMessage(
id = checkNotNull(projectionMessageId),
role = MessageRole.ASSISTANT,
content = "",
)
base.copy(
id = checkNotNull(projectionMessageId),
content = text,
reasoningText = reasoning,
isStreaming = false,
finishTimestamp = System.currentTimeMillis(),
completionId = completionId,
)
} else {
null
}
val messagesWithoutOldProjection =
existingProjection?.messageId?.let { oldId -> usageState.messages.filterNot { it.id == oldId } }
?: usageState.messages
val projectedMessages =
partial?.let { messagesWithoutOldProjection.upsertById(it) } ?: messagesWithoutOldProjection
// A failed partial is a transient projection of gateway `inflight`, not a
// successful transcript row. Persisting it would erase failure provenance on
// hydration and make a later session reopen look healthy.
val effects = listOf(ReducerEffect.RefreshSessions, ReducerEffect.RefreshContextUsage)
return ReducerResult(
state =
usageState.copy(
messages = projectedMessages,
replyFailure = failure.copy(id = failureId),
replyFailureProjection = projection,
isAgentTyping = false,
isThinking = false,
streamingMessage = null,
clarifyRequest = null,
),
streamingState = StreamingState(),
effects = effects,
)
}
// ── MessageDone ───────────────────────────────────────────────────
private fun onMessageDone(
@@ -681,6 +809,7 @@ object ChatWsEventReducer {
messages[toolIdx].copy(
toolStatus = ToolStatus.COMPLETED,
content = contentJson,
isHistoricalCache = false,
)
messages[toolIdx] = updated
@@ -928,7 +1057,11 @@ object ChatWsEventReducer {
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(progressPreview = event.preview ?: "")
messages[toolIdx] =
messages[toolIdx].copy(
progressPreview = event.preview ?: "",
isHistoricalCache = false,
)
return ReducerResult(
state = state.copy(messages = messages),
streamingState = streamingState,
@@ -944,14 +1077,14 @@ object ChatWsEventReducer {
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(progressPreview = "")
messages[toolIdx] = messages[toolIdx].copy(progressPreview = "", isHistoricalCache = false)
return ReducerResult(
state = state.copy(messages = messages),
streamingState = streamingState,
)
}
/** Match stable tool IDs first, with name matching for legacy events. */
/** Match an explicit stable ID exclusively; use name matching only when legacy events omit the ID. */
private fun findToolIndex(
messages: List<ChatMessage>,
name: String?,
@@ -965,7 +1098,8 @@ object ChatWsEventReducer {
it.toolCallId == toolId &&
it.toolStatus == status
}
if (byId >= 0) return byId
// An explicit identity must never claim a different historical invocation.
return byId
}
return messages.indexOfLast {
@@ -443,7 +443,15 @@ fun MarkdownText(
}
is MdBlock.Table -> {
MarkdownTable(block = block, textColor = textColor)
MarkdownTable(
block = block,
textColor = textColor,
latexMeasurer = latexMeasurer,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
)
}
is MdBlock.Footnotes -> {
@@ -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 view =
remember(message.content, message.toolName, message.toolStatus) {
parseToolOutput(message.content, message.toolName, message.toolStatus == ToolStatus.RUNNING)
remember(message.content, message.toolName, message.isToolRunning) {
parseToolOutput(message.content, message.toolName, message.isToolRunning)
}
val config = ToolSchemaRegistry.getDisplayConfig(message.toolName)
@@ -182,7 +182,7 @@ internal fun ToolBubble(
HeaderRow(message, config, contentColor, statusColors)
// ── Tool progress preview (tool.progress) ──
if (message.toolStatus == ToolStatus.RUNNING && !message.progressPreview.isNullOrEmpty()) {
if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) {
Text(
text = message.progressPreview,
style =
@@ -534,7 +534,7 @@ private fun HeaderRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Status icon or spinner
if (message.toolStatus == ToolStatus.RUNNING) {
if (message.isToolRunning) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
@@ -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
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
@@ -37,6 +38,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -59,6 +62,7 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.ws.CommandBlocklist
import com.m57.hermescontrol.data.ws.CommandCatalog
@@ -75,9 +79,12 @@ fun ChatInputBar(
inputFieldValue: TextFieldValue,
onInputChange: (TextFieldValue) -> Unit,
onSend: () -> Unit,
onBusySend: (BusySendMode) -> Unit = {},
onMicTap: () -> Unit,
isListening: Boolean,
isAgentTyping: Boolean,
isMainTurnBusy: Boolean = false,
canInterrupt: Boolean = false,
isConnected: Boolean,
commandCatalog: CommandCatalog,
isSessionReady: Boolean,
@@ -103,6 +110,12 @@ fun ChatInputBar(
showModelProvider: Boolean = false,
reasoningWireLevel: String? = null,
pendingReasoningLevel: String? = null,
onMicHoldStart: () -> Unit = {},
onMicHoldEnd: () -> Unit = {},
onMicHoldCancel: () -> Unit = {},
isRecordingVoice: Boolean = false,
voiceNoteAmplitude: State<Float> = remember { mutableStateOf(0f) },
onStopGeneration: () -> Unit = {},
) {
// Allow sending while the agent is mid-turn or awaiting approval: the
// gateway's prompt.submit busy-input policy queues it as the next turn
@@ -111,10 +124,14 @@ fun ChatInputBar(
val canSend =
pendingReasoningLevel == null &&
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty()
val isSlashDraft = inputFieldValue.text.trimStart().startsWith("/")
val showBusyActions = isMainTurnBusy && hasDraft && !isSlashDraft
// Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) }
val palette = composerPalette()
BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false }
AnimatedVisibility(
visible = true,
@@ -260,55 +277,64 @@ fun ChatInputBar(
}
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
BasicTextField(
value = inputFieldValue,
onValueChange = onInputChange,
modifier =
Modifier
.weight(1f)
.heightIn(min = 42.dp, max = 200.dp)
.padding(vertical = 4.dp)
.testTag("chat_input"),
enabled = isConnected,
textStyle =
MaterialTheme.typography.bodyLarge.copy(
color = palette.text,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
),
singleLine = false,
maxLines = 8,
cursorBrush = SolidColor(palette.text),
decorationBox = { innerTextField ->
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment =
if (isInputRtl) {
Alignment.CenterEnd
} else {
Alignment.CenterStart
},
) {
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
if (inputFieldValue.text.isEmpty()) {
Text(
text = placeholderText,
style = MaterialTheme.typography.bodyLarge,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
color = palette.placeholder,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
if (isRecordingVoice) {
VoiceNoteRecordingPanel(
amplitude = voiceNoteAmplitude,
modifier = Modifier.weight(1f),
)
} else {
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
BasicTextField(
value = inputFieldValue,
onValueChange = onInputChange,
modifier =
Modifier
.weight(1f)
.heightIn(min = 42.dp, max = 200.dp)
.padding(vertical = 4.dp)
.testTag("chat_input"),
enabled = isConnected,
textStyle =
MaterialTheme.typography.bodyLarge.copy(
color = palette.text,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
),
singleLine = false,
maxLines = 8,
cursorBrush = SolidColor(palette.text),
decorationBox = { innerTextField ->
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment =
if (isInputRtl) {
Alignment.CenterEnd
} else {
Alignment.CenterStart
},
) {
CompositionLocalProvider(
LocalLayoutDirection provides inputLayoutDirection,
) {
if (inputFieldValue.text.isEmpty()) {
Text(
text = placeholderText,
style = MaterialTheme.typography.bodyLarge,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
color = palette.placeholder,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
innerTextField()
}
innerTextField()
}
}
}
},
)
},
)
}
}
}
@@ -319,12 +345,20 @@ fun ChatInputBar(
reasoningLevel = reasoningLevel,
isListening = isListening,
canSend = canSend,
showSend = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty(),
showSend = hasDraft,
showQueue = showBusyActions,
onSend = onSend,
onQueue = { onBusySend(BusySendMode.QUEUE) },
onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) },
canInterrupt = canInterrupt,
onStopGeneration = onStopGeneration,
onAttachTap = { showAttachmentTray = !showAttachmentTray },
onModelTap = onModelTap,
onReasoningSelected = onReasoningTap,
onMicTap = onMicTap,
onMicHoldStart = onMicHoldStart,
onMicHoldEnd = onMicHoldEnd,
onMicHoldCancel = onMicHoldCancel,
modifier = Modifier.testTag("chat_composer_toolbar"),
canDisableReasoning = canDisableReasoning,
supportsReasoning = supportsReasoning,
@@ -12,6 +12,9 @@ import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -27,6 +30,8 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder
import kotlinx.coroutines.delay
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
@@ -34,10 +39,15 @@ import java.util.Locale
class ChatMediaLaunchers(
val isListening: Boolean,
val isRecordingVoice: Boolean,
val voiceNoteAmplitude: State<Float>,
val onMicTap: () -> Unit,
val onCameraTap: () -> Unit,
val onImageTap: () -> Unit,
val onFileTap: () -> Unit,
val onMicHoldStart: () -> Unit = {},
val onMicHoldEnd: () -> Unit = {},
val onMicHoldCancel: () -> Unit = {},
)
@Composable
@@ -46,8 +56,10 @@ fun rememberChatMediaLaunchers(
onInputFieldValueChange: (TextFieldValue) -> Unit,
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
onAddAttachments: (List<Attachment>) -> Unit,
onVoiceNoteRecorded: (file: File) -> Unit,
onShowMessage: (String) -> Unit,
launchExternalActivity: (() -> Unit) -> Unit,
isTranscribingVoiceNote: Boolean,
context: Context = LocalContext.current,
): ChatMediaLaunchers {
var isListening by remember { mutableStateOf(false) }
@@ -57,6 +69,7 @@ fun rememberChatMediaLaunchers(
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded)
val currentOnShowMessage by rememberUpdatedState(onShowMessage)
val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity)
@@ -64,6 +77,59 @@ fun rememberChatMediaLaunchers(
val sttNotAvailableMsg = stringResource(R.string.stt_not_available)
val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied)
val cameraErrorMsg = stringResource(R.string.chat_camera_error)
val voiceRecordFailedMsg = stringResource(R.string.chat_voice_record_failed)
// Hold-to-record voice note recorder: the clip uploads to the dashboard
// for server-side transcription, unlike the flat tap path below, which
// still drives the on-device recognizer.
val voiceNoteRecorder = remember { VoiceNoteRecorder(context) }
var isRecordingVoice by remember { mutableStateOf(false) }
// Live mic level for the recording panel — rises fast, decays slowly so
// the meter reads as voice activity instead of flicker.
val voiceNoteAmplitude = remember { mutableStateOf(0f) }
LaunchedEffect(Unit) {
while (true) {
delay(90)
val previous = voiceNoteAmplitude.value
val next =
if (voiceNoteRecorder.isActive) {
maxOf(voiceNoteRecorder.currentAmplitude() / 32767f, previous * 0.8f)
} else {
0f
}
if (next != previous) {
voiceNoteAmplitude.value = next
}
}
}
fun finishVoiceRecording() {
val recordedFile = voiceNoteRecorder.stop()
isRecordingVoice = false
if (recordedFile != null) {
currentOnVoiceNoteRecorded(recordedFile)
}
}
// App-owned max-duration cap: the timer drives the same
// finishVoiceRecording() path as the hold release, so exactly one code
// path ever calls stop() — MediaRecorder's own auto-stop used to race
// the release and discard a valid max-length clip (review, PR #1250).
LaunchedEffect(isRecordingVoice) {
if (isRecordingVoice) {
delay(VoiceNoteRecorder.MAX_DURATION_MS.toLong())
if (voiceNoteRecorder.isActive) {
finishVoiceRecording()
}
}
}
DisposableEffect(voiceNoteRecorder) {
onDispose {
voiceNoteRecorder.cancel()
}
}
// Speech-to-text recognition launcher (issue #194)
val speechLauncher =
@@ -117,6 +183,18 @@ fun rememberChatMediaLaunchers(
}
}
// Hold-to-record permission: unlike the launcher above, granting does NOT
// auto-start dictation — the user records by holding the mic again.
val voiceNotePermissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
ExternalActivityLifecycleGuard.externalActivityReturned()
if (!granted) {
currentOnShowMessage(sttPermissionDeniedMsg)
}
}
// Multi-file picker for attachments (issue #195)
val filePickerLauncher =
rememberLauncherForActivityResult(
@@ -183,7 +261,14 @@ fun rememberChatMediaLaunchers(
}
val onMicTap: () -> Unit = {
if (isListening) {
if (isTranscribingVoiceNote) {
// Single-flight: a server transcription owns the voice pipeline
// until it lands (review, PR #1250).
} else if (isRecordingVoice) {
// A tap while recording discards the in-flight voice note.
voiceNoteRecorder.cancel()
isRecordingVoice = false
} else if (isListening) {
isListening = false
} else if (
ContextCompat.checkSelfPermission(
@@ -212,6 +297,45 @@ fun rememberChatMediaLaunchers(
}
}
val onMicHoldStart: () -> Unit = {
if (isTranscribingVoiceNote) {
// Single-flight: no new recording while a transcription runs.
} else if (voiceNoteRecorder.isActive || isListening) {
// A voice note or a dictation session is already running.
} else if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
) {
if (voiceNoteRecorder.start()) {
isRecordingVoice = true
} else {
currentOnShowMessage(voiceRecordFailedMsg)
}
} else {
// The denial message comes from voiceNotePermissionLauncher's
// result callback only — showing it here fired it before the
// dialog even opened (review, PR #1250).
currentLaunchExternalActivity {
voiceNotePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
}
val onMicHoldEnd: () -> Unit = {
if (voiceNoteRecorder.isActive) {
finishVoiceRecording()
}
}
val onMicHoldCancel: () -> Unit = {
if (voiceNoteRecorder.isActive) {
voiceNoteRecorder.cancel()
}
isRecordingVoice = false
}
val onCameraTap: () -> Unit = {
try {
val timeStamp =
@@ -245,13 +369,18 @@ fun rememberChatMediaLaunchers(
}
}
return remember(isListening) {
return remember(isListening, isRecordingVoice, isTranscribingVoiceNote) {
ChatMediaLaunchers(
isListening = isListening,
isListening = isListening || isRecordingVoice,
isRecordingVoice = isRecordingVoice,
voiceNoteAmplitude = voiceNoteAmplitude,
onMicTap = onMicTap,
onCameraTap = onCameraTap,
onImageTap = onImageTap,
onFileTap = onFileTap,
onMicHoldStart = onMicHoldStart,
onMicHoldEnd = onMicHoldEnd,
onMicHoldCancel = onMicHoldCancel,
)
}
}
@@ -6,8 +6,12 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -27,6 +31,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AddToQueue
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Stop
@@ -48,20 +53,27 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import kotlinx.coroutines.withTimeoutOrNull
/**
* Bottom controls row for the chat composer, rendered inside the composer card.
@@ -83,6 +95,7 @@ import com.m57.hermescontrol.R
* When [supportsReasoning] is false the model takes no reasoning parameter
* and the level menu is disabled.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ComposerToolbar(
isConnected: Boolean,
@@ -96,7 +109,10 @@ fun ComposerToolbar(
modifier: Modifier = Modifier,
canSend: Boolean = false,
showSend: Boolean = canSend,
showQueue: Boolean = false,
onSend: () -> Unit = {},
onQueue: () -> Unit = {},
onStopAndSend: () -> Unit = {},
canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null,
fastMode: Boolean = false,
@@ -107,12 +123,50 @@ fun ComposerToolbar(
reasoningWireLevel: String? = null,
pendingReasoningLevel: String? = null,
isSessionReady: Boolean = true,
canInterrupt: Boolean = false,
onMicHoldStart: () -> Unit = {},
onMicHoldEnd: () -> Unit = {},
onMicHoldCancel: () -> Unit = {},
onStopGeneration: () -> Unit = {},
) {
var showReasoningMenu by remember { mutableStateOf(false) }
val palette = composerPalette()
val reasoningDisabledForModel = supportsReasoning == false
val canDisable = canDisableReasoning
// Telegram-style voice notes: hold to record, release to send, slide away
// to cancel. Both mic controls run the same single pointer loop
// (micHoldHandler), which owns the whole press until finger-up — so the
// release that submits the note can never be lost between recompositions.
val currentIsConnected = rememberUpdatedState(isConnected)
val currentShowSend = rememberUpdatedState(showSend)
val currentCanInterrupt = rememberUpdatedState(canInterrupt)
val currentOnMicHoldStart = rememberUpdatedState(onMicHoldStart)
val currentOnMicHoldEnd = rememberUpdatedState(onMicHoldEnd)
val currentOnMicHoldCancel = rememberUpdatedState(onMicHoldCancel)
val flatMicGesture =
Modifier.pointerInput(Unit) {
micHoldHandler(
isEnabled = {
currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value
},
onHoldStart = { currentOnMicHoldStart.value() },
onHoldEnd = { currentOnMicHoldEnd.value() },
onHoldCancel = { currentOnMicHoldCancel.value() },
)
}
val actionMicGesture =
Modifier.pointerInput(Unit) {
micHoldHandler(
isEnabled = {
currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value
},
onHoldStart = { currentOnMicHoldStart.value() },
onHoldEnd = { currentOnMicHoldEnd.value() },
onHoldCancel = { currentOnMicHoldCancel.value() },
)
}
Row(
modifier =
modifier
@@ -408,55 +462,118 @@ fun ComposerToolbar(
}
}
// Flat mic / stop button — only while the action button is in send mode
// Flat slot — mic while idle; while a generation can be interrupted it
// keeps queue-send available (the action button carries Stop). During
// session preparation the mic stays here and the action button is a
// disabled send: Stop must not appear when there is nothing to
// interrupt (review, PR #1250).
AnimatedVisibility(
visible = showSend,
enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut(targetScale = 0.8f),
) {
if (canInterrupt) {
FilledIconButton(
onClick = onSend,
enabled = canSend,
colors = flatIconButtonColors(palette),
modifier =
Modifier
.size(ControlSize)
.testTag("send_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc),
)
}
} else {
FilledIconButton(
onClick = onMicTap,
enabled = isConnected,
colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette),
modifier =
Modifier
.size(ControlSize)
.testTag(if (isListening) "mic_stop_button" else "mic_button")
.then(flatMicGesture),
) {
Icon(
imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic,
contentDescription = if (isListening) "Stop listening" else "Mic",
)
}
}
}
AnimatedVisibility(
visible = showQueue,
enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut(targetScale = 0.8f),
) {
FilledIconButton(
onClick = onMicTap,
enabled = isConnected,
colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette),
modifier =
Modifier
.size(ControlSize)
.testTag(if (isListening) "mic_stop_button" else "mic_button"),
onClick = onQueue,
enabled = canSend,
colors = flatIconButtonColors(palette),
modifier = Modifier.size(48.dp).testTag("queue_button"),
) {
Icon(
imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic,
contentDescription = if (isListening) "Stop listening" else "Mic",
imageVector = Icons.Default.AddToQueue,
contentDescription = stringResource(R.string.chat_busy_queue_action),
modifier = Modifier.size(20.dp),
)
}
}
// Action button — send when a send is possible, mic / stop otherwise
FilledIconButton(
onClick = if (showSend) onSend else onMicTap,
enabled = if (showSend) canSend else isConnected,
colors =
if (!showSend && isListening) {
listeningIconButtonColors()
} else {
IconButtonDefaults.filledIconButtonColors(
containerColor = palette.action,
contentColor = palette.onAction,
)
},
val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send)
val actionEnabled = if (showSend) canSend else isConnected
Box(
modifier =
Modifier
.size(ControlSize)
.testTag(
.size(if (showSend) 48.dp else ControlSize)
.clip(CircleShape)
.background(
if (!showSend && isListening) {
MaterialTheme.colorScheme.errorContainer
} else {
palette.action
},
).semantics {
if (showSend && showQueue && canSend) {
customActions =
listOf(
CustomAccessibilityAction(stopAndSendLabel) {
onStopAndSend()
true
},
)
}
}.combinedClickable(
enabled = actionEnabled,
onClick = {
when {
canInterrupt -> onStopGeneration()
showSend -> onSend()
else -> onMicTap()
}
},
onLongClick = if (showSend && showQueue) onStopAndSend else null,
onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null,
).testTag(
when {
canInterrupt -> "stop_button"
showSend -> "send_button"
isListening -> "mic_stop_button"
else -> "mic_button"
},
),
).then(if (showSend || canInterrupt) Modifier else actionMicGesture),
contentAlignment = Alignment.Center,
) {
Crossfade(
targetState =
when {
canInterrupt -> ActionGlyph.STOP
showSend -> ActionGlyph.SEND
isListening -> ActionGlyph.STOP
else -> ActionGlyph.VOICE
@@ -468,15 +585,34 @@ fun ComposerToolbar(
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc),
tint = palette.onAction,
)
}
ActionGlyph.STOP -> {
Icon(imageVector = Icons.Default.Stop, contentDescription = "Stop listening")
Icon(
imageVector = Icons.Default.Stop,
contentDescription =
if (canInterrupt) {
stringResource(R.string.chat_voice_stop_generating)
} else {
"Stop listening"
},
tint =
if (isListening && !canInterrupt) {
MaterialTheme.colorScheme.onErrorContainer
} else {
palette.onAction
},
)
}
ActionGlyph.VOICE -> {
Icon(imageVector = Icons.Outlined.Mic, contentDescription = "Mic")
Icon(
imageVector = Icons.Outlined.Mic,
contentDescription = "Mic",
tint = palette.onAction,
)
}
}
}
@@ -581,3 +717,91 @@ fun buildReasoningLabel(
}
return reqLabel
}
/**
* Telegram-style mic gesture: press and hold past [HOLD_TO_RECORD_THRESHOLD_MS]
* records a voice note, release sends it, and sliding away (left or up) from
* the button cancels instead of sending. Short taps use the control's normal
* click handler for dictation, so touch and accessibility actions share one
* callback path.
*
* The whole down-to-up sequence lives in this one pointer loop, so the release
* that submits can never be lost to a recomposition between press and
* finger-up — an earlier interaction-source based approach could lose it and
* leave the recorder running until some later tap "sent" the stale clip.
*/
private suspend fun PointerInputScope.micHoldHandler(
isEnabled: () -> Boolean,
onHoldStart: () -> Unit,
onHoldEnd: () -> Unit,
onHoldCancel: () -> Unit,
) {
val cancelSlop = MIC_SLIDE_CANCEL_DP.dp.toPx()
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
if (!isEnabled()) {
return@awaitEachGesture
}
fun slidAway(position: Offset): Boolean {
val dx = position.x - down.position.x
val dy = position.y - down.position.y
return dx < -cancelSlop || dy < -cancelSlop
}
// Phase 1 — a finger-up before the threshold is a plain tap.
var earlyUp = false
var gestureAborted = false
withTimeoutOrNull(HOLD_TO_RECORD_THRESHOLD_MS) {
while (true) {
val event = awaitPointerEvent()
val change =
event.changes.firstOrNull { it.id == down.id }
?: run {
gestureAborted = true
return@withTimeoutOrNull
}
if (!change.pressed) {
earlyUp = true
return@withTimeoutOrNull
}
if (change.isConsumed || slidAway(change.position)) {
gestureAborted = true
return@withTimeoutOrNull
}
}
}
if (gestureAborted) {
return@awaitEachGesture
}
if (earlyUp) {
return@awaitEachGesture
}
// Phase 2 — the threshold passed with the finger down: record until
// the finger lifts (send) or slides away (cancel). No timeout here.
onHoldStart()
var send = false
try {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) {
send = true
break
}
if (change.isConsumed || slidAway(change.position)) {
break
}
}
} finally {
if (send) onHoldEnd() else onHoldCancel()
}
}
}
/** A press shorter than this is a tap; longer arms voice-note recording. */
private const val HOLD_TO_RECORD_THRESHOLD_MS = 400L
/** Sliding this far from the mic button (left or up) cancels the recording. */
private const val MIC_SLIDE_CANCEL_DP = 64
@@ -89,6 +89,7 @@ import com.m57.hermescontrol.theme.CodeTerminalBorder
import com.m57.hermescontrol.theme.CodeTerminalMuted
import com.m57.hermescontrol.theme.CodeTerminalText
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.chat.SubagentIndicator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -101,7 +102,8 @@ import kotlinx.coroutines.withContext
* (reasoning-model thinking steps) before the final answer.
*
* Collapsed: "🧠 Reasoning · {N} steps" with chevron.
* Expanded: reasoning text in monospace bodySmall, capped at ~40% of the
* Expanded: completed reasoning is Markdown; streaming text stays raw,
* capped at ~40% of the
* screen height with internal scroll; "Show full" lifts the cap.
* Long-press anywhere: copies the reasoning trace to the clipboard.
* Streaming: pulsing indicator at the bottom while [isStreaming].
@@ -180,26 +182,35 @@ fun ReasoningCard(
}
AnimatedVisibility(visible = expanded) {
Column {
Text(
text = reasoningText,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.padding(top = 4.dp)
.then(
if (!fullHeight) {
// Capped at ~40% of the screen with internal scroll.
Modifier.heightIn(max = capHeight).verticalScroll(scrollState)
} else {
// "Show full": render the whole trace at natural height.
// A scrollable must NEVER be measured with an infinite
// max height — inside a LazyColumn item (unbounded main
// axis) that combination throws IllegalStateException.
Modifier
},
),
)
val contentModifier =
Modifier
.padding(top = 4.dp)
.then(
if (!fullHeight) {
// Capped at ~40% of the screen with internal scroll.
Modifier.heightIn(max = capHeight).verticalScroll(scrollState)
} else {
// "Show full": render the whole trace at natural height.
// A scrollable must NEVER be measured with an infinite
// max height — inside a LazyColumn item (unbounded main
// axis) that combination throws IllegalStateException.
Modifier
},
)
if (isStreaming) {
Text(
text = reasoningText,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = contentModifier,
)
} else {
MarkdownText(
text = reasoningText,
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = contentModifier,
)
}
if (isStreaming) {
ReasoningPulsingDot(modifier = Modifier.padding(top = 6.dp))
}
@@ -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.StreamingState
import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry
import com.m57.hermescontrol.ui.chat.ToolStatus
import com.m57.hermescontrol.ui.chat.components.TypingIndicator
import com.m57.hermescontrol.ui.chat.isToolRunning
import com.m57.hermescontrol.ui.chat.isUserTurnBoundary
/** Live status for an agent turn before visible assistant prose is available. */
@@ -61,7 +61,7 @@ internal fun deriveAgentStatus(
.asReversed()
.firstOrNull {
it.role == MessageRole.TOOL &&
it.toolStatus == ToolStatus.RUNNING
it.isToolRunning
}
val hasVisibleStreamingContent = streamingState.streamingMessage?.content?.isNotBlank() == true
@@ -104,8 +104,9 @@ fun FullBleedChatList(
hasOlderMessages: Boolean = false,
pagingSessionId: String? = null,
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
replyErrorContent: (@Composable () -> Unit)? = null,
) {
if (messages.isEmpty() && !isLoading && !isAgentTyping) {
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
@@ -145,6 +146,7 @@ fun FullBleedChatList(
// A single ordered definition supplies both emitted tail rows and anchor keys.
val tailItems =
buildMap<String, @Composable () -> Unit> {
replyErrorContent?.let { put("reply_error", it) }
agentStatus?.let { status ->
put("agent_status") {
AgentStatusIndicator(status = status)
@@ -66,33 +66,84 @@ object MarkdownInlineStyler {
return buildAnnotatedString {
var i = 0
val src = text
while (i < src.length) {
when {
// ***bold italic***
src.startsWith("***", i) -> {
val end = src.indexOf("***", i + 3)
if (end != -1) {
val raw = src.substring(i + 3, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
append(toAppend)
// Inline code is opaque to emphasis parsing, and delimiters must match by run length.
src[i] == '`' -> {
var runEnd = i
while (runEnd < src.length && src[runEnd] == '`') runEnd++
val runLength = runEnd - i
var end = runEnd
var matchingEnd = -1
while (end < src.length) {
if (src[end] == '`') {
var candidateEnd = end
while (candidateEnd < src.length && src[candidateEnd] == '`') candidateEnd++
if (candidateEnd - end == runLength) {
matchingEnd = end
break
}
end = candidateEnd
} else {
end++
}
i = end + 3
}
if (matchingEnd >= 0) {
val raw = src.substring(runEnd, matchingEnd)
val content = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
background = textColor.copy(alpha = 0.08f),
),
) {
append(content)
}
i = matchingEnd + runLength
} else {
append(src[i])
i++
append(src.substring(i, runEnd))
i = runEnd
}
}
// Combined / nested emphasis delimiters.
src.startsWith("***", i) -> {
val end = src.indexOf("***", i + 3)
if (end >= 0) {
val raw = src.substring(i + 3, end)
val content = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
append(content)
}
i = end + 3
} else {
append("***")
i += 3
}
}
// **bold**
src.startsWith("**", i) -> {
val end = src.indexOf("**", i + 2)
var end = src.indexOf("**", i + 2)
if (end >= 0 && src.startsWith("***", end) &&
src.substring(i + 2, end).count { it == '*' } % 2 == 1
) {
end++ // Inner italic closes with the first star of the trailing run.
}
if (end != -1) {
val raw = src.substring(i + 2, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(toAppend)
}
val nested =
parseInlineSource(
raw,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
isRtl,
)
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(nested) }
i = end + 2
} else {
append(src[i])
@@ -116,16 +167,24 @@ object MarkdownInlineStyler {
}
}
// *italic*
// *italic*; bold spans keep their styles while nested text is appended.
src.startsWith("*", i) -> {
val end = src.indexOf('*', i + 1)
if (end != -1 && end > i + 1) {
val raw = src.substring(i + 1, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(toAppend)
}
i = end + 1
val close = src.indexOf('*', i + 1)
if (close > i + 1) {
val raw = src.substring(i + 1, close)
val italic = SpanStyle(fontStyle = FontStyle.Italic)
val nested =
parseInlineSource(
raw,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
isRtl,
)
withStyle(italic) { append(nested) }
i = close + 1
} else {
append(src[i])
i++
@@ -225,16 +284,20 @@ object MarkdownInlineStyler {
val urlEnd = src.indexOf(')', close + 2)
if (urlEnd != -1) {
val label = src.substring(i + 1, close)
val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label
val url = src.substring(close + 2, urlEnd)
pushLink(LinkAnnotation.Url(url))
withStyle(
SpanStyle(
color = linkColor,
textDecoration = TextDecoration.Underline,
),
) {
append(labelToAppend)
val labelText =
parseInlineSource(
label,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
isRtl,
)
withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) {
append(labelText)
}
pop()
i = urlEnd + 1
@@ -248,28 +311,6 @@ object MarkdownInlineStyler {
}
}
// `inline code`
src.startsWith("`", i) -> {
val end = src.indexOf('`', i + 1)
if (end != -1) {
val raw = src.substring(i + 1, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
background = textColor.copy(alpha = 0.08f),
),
) {
append(toAppend)
}
i = end + 1
} else {
append(src[i])
i++
}
}
// bare URL
URL_PATTERN.matchAt(src, i) != null -> {
val match = URL_PATTERN.matchAt(src, i)!!
@@ -10,18 +10,19 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.hrm.latex.renderer.measure.LatexMeasurerState
import com.m57.hermescontrol.theme.SearchHighlightColors
import com.m57.hermescontrol.util.BidiUtils
private val TABLE_COL_WIDTH = 140.dp
@@ -30,6 +31,11 @@ private val TABLE_COL_WIDTH = 140.dp
fun MarkdownTable(
block: MdBlock.Table,
textColor: Color,
latexMeasurer: LatexMeasurerState,
searchQuery: String,
isCurrentMatch: Boolean,
linkColor: Color,
highlights: SearchHighlightColors,
modifier: Modifier = Modifier,
) {
val isRtl =
@@ -49,18 +55,21 @@ fun MarkdownTable(
.padding(vertical = 4.dp),
) {
// Header row
Row(modifier = Modifier.background(headerBg)) {
Row(modifier = Modifier.background(headerBg), verticalAlignment = Alignment.Top) {
block.header.forEachIndexed { idx, cell ->
val cellRtl = BidiUtils.isRtlText(cell)
Text(
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
textAlign = tableTextAlign(alignments.getOrNull(idx)),
color = textColor,
MarkdownInlineText(
text = cell,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodySmall.copy(
fontWeight = FontWeight.Bold,
textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr,
textAlign = tableTextAlign(alignments.getOrNull(idx)),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier =
Modifier
.width(TABLE_COL_WIDTH)
@@ -71,17 +80,20 @@ fun MarkdownTable(
HorizontalDivider(color = textColor.copy(alpha = 0.25f))
// Body rows
block.rows.forEach { row ->
Row {
Row(verticalAlignment = Alignment.Top) {
row.forEachIndexed { idx, cell ->
val cellRtl = BidiUtils.isRtlText(cell)
Text(
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
textAlign = tableTextAlign(alignments.getOrNull(idx)),
color = textColor,
MarkdownInlineText(
text = cell,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodySmall.copy(
textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr,
textAlign = tableTextAlign(alignments.getOrNull(idx)),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier =
Modifier
.width(TABLE_COL_WIDTH)
@@ -95,9 +107,9 @@ fun MarkdownTable(
}
}
private fun tableTextAlign(align: TableAlign?): TextAlign? =
private fun tableTextAlign(align: TableAlign?): TextAlign =
when (align) {
TableAlign.CENTER -> TextAlign.Center
TableAlign.RIGHT -> TextAlign.End
else -> null
else -> TextAlign.Start
}
@@ -193,6 +193,8 @@ internal fun SettingsChatPage(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ChatSection(
busySendMode = state.busySendMode,
onBusySendModeChange = viewModel::onBusySendModeChange,
typingEffectEnabled = state.typingEffectEnabled,
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
typingEffectDelayMs = state.typingEffectDelayMs,
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.config.resolveBaseUrl
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SessionListCacheStore
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -37,6 +38,7 @@ data class SettingsUiState(
val testResult: String? = null,
val isSaved: Boolean = false,
val typingEffectEnabled: Boolean = false,
val busySendMode: BusySendMode = BusySendMode.CORRECT,
val typingEffectDelayMs: Int = 30,
val chatFontScale: Float = 1.0f,
val messageStatsEnabled: Boolean = false,
@@ -85,6 +87,7 @@ class SettingsViewModel(
val useDynamicColors = AuthManager.isUseDynamicColors()
val themePreset = AuthManager.getThemePreset()
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
val busySendMode = AuthManager.getBusySendMode()
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
val chatFontScale = AuthManager.getChatFontScale()
val messageStatsEnabled = AuthManager.isMessageStatsEnabled()
@@ -112,6 +115,7 @@ class SettingsViewModel(
useDynamicColors = useDynamicColors,
themePreset = themePreset,
typingEffectEnabled = typingEffectEnabled,
busySendMode = busySendMode,
typingEffectDelayMs = typingEffectDelayMs,
chatFontScale = chatFontScale,
messageStatsEnabled = messageStatsEnabled,
@@ -336,6 +340,11 @@ class SettingsViewModel(
AuthManager.setTypingEffectEnabled(enabled)
}
fun onBusySendModeChange(mode: BusySendMode) {
_uiState.update { it.copy(busySendMode = mode, isSaved = false) }
AuthManager.setBusySendMode(mode)
}
fun onTypingEffectDelayMsChange(delayMs: Int) {
_uiState.update { it.copy(typingEffectDelayMs = delayMs, isSaved = false) }
AuthManager.setTypingEffectDelayMs(delayMs)
@@ -38,8 +38,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.ui.chat.label
import com.m57.hermescontrol.ui.settings.SectionCard
import kotlin.math.abs
import kotlin.math.roundToInt
@@ -215,6 +217,8 @@ internal fun ChatSection(
onTokensPerSecondChange: (Boolean) -> Unit = {},
showModelProvider: Boolean = false,
onShowModelProviderChange: (Boolean) -> Unit = {},
busySendMode: BusySendMode = BusySendMode.CORRECT,
onBusySendModeChange: (BusySendMode) -> Unit = {},
) {
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
val currentIndex =
@@ -223,6 +227,38 @@ internal fun ChatSection(
.takeIf { it >= 0 } ?: 1
SectionCard {
var busyModeExpanded by remember { mutableStateOf(false) }
Text(
text = stringResource(R.string.chat_busy_mode_title),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.chat_busy_mode_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Box {
OutlinedButton(onClick = { busyModeExpanded = true }, modifier = Modifier.testTag("busy_send_default")) {
Text(busySendMode.label())
}
DropdownMenu(expanded = busyModeExpanded, onDismissRequest = { busyModeExpanded = false }) {
BusySendMode.entries.forEach { mode ->
DropdownMenuItem(
text = { Text(mode.label()) },
onClick = {
onBusySendModeChange(mode)
busyModeExpanded = false
},
)
}
}
}
Text(
text = stringResource(R.string.chat_busy_interrupt_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
+23 -1
View File
@@ -1543,6 +1543,7 @@
<string name="media_player_play_video">تشغيل الفيديو</string>
<string name="chat_session_preparing">جارٍ تجهيز الجلسة…</string>
<string name="chat_session_not_ready">الجلسة غير جاهزة — أعد المحاولة للمتابعة</string>
<string name="connection_setup_target_title">إعداد %1$s</string>
<string name="connection_setup_progress">%1$d من %2$d</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_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string>
<string name="connection_setup_pending">بانتظار Hermes…</string>
</resources>
<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>
+23 -1
View File
@@ -1199,6 +1199,7 @@
<string name="media_player_play_video">비디오 재생</string>
<string name="chat_session_preparing">세션 준비 중…</string>
<string name="chat_session_not_ready">세션이 준비되지 않았습니다. 다시 시도하세요</string>
<string name="connection_setup_target_title">%1$s 설정</string>
<string name="connection_setup_progress">%2$d개 중 %1$d개</string>
<string name="connection_setup_authorized_no_tools">인증되었지만 도구를 사용할 수 없습니다</string>
@@ -1239,4 +1240,25 @@
<string name="kanban_attachment_error">첨부 파일 오류: %1$s</string>
<string name="kanban_active_workers">활성 작업자</string>
<string name="kanban_no_active_workers">활성 작업자가 없습니다</string>
</resources>
<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>
+120
View File
@@ -1509,4 +1509,124 @@
<string name="connection_setup_required">%1$s为必填项</string>
<string name="connection_setup_request_failed">无法发送设置响应,请重试。</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>
+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 -->
<string name="chat_send_desc">Send</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_not_available">Speech recognition is not available on this device</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_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_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_exit_code">Exit Code: %1$d</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
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageProvenance
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.ToolStatus
import org.junit.Assert.assertEquals
@@ -187,6 +188,32 @@ class ChatMessageMapperTest {
assertEquals(MessageRole.USER, roundTripped.role)
}
@Test
fun roundTripPreservesLocalPendingProvenance() {
val roundTripped =
ChatMessage(
id = "pending",
role = MessageRole.USER,
content = "not submitted",
messageProvenance = MessageProvenance.LOCAL_PENDING,
).toEntity("s").toUiModel()
assertEquals(MessageProvenance.LOCAL_PENDING, roundTripped.messageProvenance)
}
@Test
fun unknownPersistedProvenanceDoesNotInventDeliveryEvidence() {
val entity =
ChatMessageEntity(
id = "legacy",
sessionId = "s",
role = "USER",
content = "ambiguous",
timestamp = 1L,
messageProvenance = "future-value",
)
assertEquals(MessageProvenance.UNKNOWN, entity.toUiModel().messageProvenance)
}
@Test
fun roundTripWithoutDisplayKindStaysNull() {
val ui =
@@ -97,4 +97,16 @@ class HermesDatabaseMigrationsTest {
)
}
}
@Test
fun migration9to10_addsDurableProvenanceWithoutInventingDeliveryState() =
runBlocking {
val connection = mockk<SQLiteConnection>(relaxed = true)
HermesDatabase.MIGRATION_9_10.migrate(connection)
verify(exactly = 1) {
connection.execSQL(
"ALTER TABLE chat_messages ADD COLUMN message_provenance TEXT NOT NULL DEFAULT 'UNKNOWN'",
)
}
}
}
@@ -2,8 +2,10 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -59,6 +61,7 @@ class ProfileScopeInterceptorTest {
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
val builder = Request.Builder().url(server.url(path))
when (method) {
"POST" -> builder.post("{}".toRequestBody("application/json".toMediaType()))
"DELETE" -> builder.delete()
else -> builder.get()
}
@@ -269,4 +272,27 @@ class ProfileScopeInterceptorTest {
assertEquals("work", url.queryParameter("profile"))
assertEquals("/api/status/health", url.encodedPath)
}
@Test
fun transcribeRoute_getsTheActiveNonDefaultProfile() {
// Review (PR #1250): /api/audio/transcribe resolves STT through the
// request's profile on the backend — the desktop sends this route
// `...profileScoped()`. Unscoped, a multi-profile host would
// transcribe under the launch profile instead of the user's pick.
val client = clientFor("work")
val url = requestedUrl(client, "api/audio/transcribe", method = "POST")
assertEquals("work", url.queryParameter("profile"))
assertEquals("/api/audio/transcribe", url.encodedPath)
}
@Test
fun lookalikeAudioPath_notScoped() {
val client = clientFor("work")
val url = requestedUrl(client, "api/audio-extras")
assertNull(url.queryParameter("profile"))
}
}
@@ -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
import android.content.Context
import com.m57.hermescontrol.data.ws.WsEvent
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
@@ -104,4 +108,59 @@ class ChatNotificationServiceTest {
atomicBoolean.get(),
)
}
@Test
fun `terminal reply error uses generic failure notification without success affordances`() {
val rawDiagnostic = "Authorization: Bearer secret-token"
val plan =
messageCompleteNotificationPlan(
event =
WsEvent.MessageComplete(
text = rawDiagnostic,
sessionId = "runtime-1",
storedSessionId = "stored-1",
completionId = "completion-1",
rawPayload =
mapOf(
"status" to "error",
"error" to rawDiagnostic,
),
),
targetSessionId = "stored-1",
newMessageText = "New message",
failureText = "Hermes couldn't finish this reply",
)
assertEquals("Hermes couldn't finish this reply", plan.text)
assertFalse(plan.text.contains(rawDiagnostic))
assertEquals("stored-1", plan.sessionId)
assertFalse(plan.isReplyMessage)
assertFalse(plan.allowInlineReply)
assertNull(plan.completionId)
assertNull(plan.correlationText)
}
@Test
fun `successful completion keeps reply correlation and inline reply`() {
val plan =
messageCompleteNotificationPlan(
event =
WsEvent.MessageComplete(
text = "Answer\ncontinued",
sessionId = "runtime-1",
completionId = "completion-1",
rawPayload = mapOf("status" to "ok"),
),
targetSessionId = "stored-1",
newMessageText = "New message",
failureText = "Hermes couldn't finish this reply",
)
assertEquals("Answer continued", plan.text)
assertEquals("stored-1", plan.sessionId)
assertTrue(plan.isReplyMessage)
assertTrue(plan.allowInlineReply)
assertEquals("completion-1", plan.completionId)
assertEquals("Answer\ncontinued", plan.correlationText)
}
}
@@ -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
class ChatPagingMergeTest {
@Test
fun lateCachedRunningToolCannotReplaceCanonicalServerResult() {
val canonical =
ChatMessage(
id = "rest-s-42",
role = MessageRole.TOOL,
content = """{"output":"server result","exit_code":0}""",
toolCallId = "call-42",
toolStatus = ToolStatus.COMPLETED,
)
val cached =
ChatMessage(
id = "cached-tool",
role = MessageRole.TOOL,
content = """{"name":"terminal","args":{"command":"pwd"}}""",
toolName = "terminal",
toolCallId = "call-42",
toolStatus = ToolStatus.RUNNING,
isHistoricalCache = true,
tokenCount = 7,
tps = 2.5,
finishTimestamp = 123L,
)
val merged = mergeCachedTranscriptPage(listOf(cached), listOf(canonical)).single()
assertEquals(canonical.id, merged.id)
assertEquals(canonical.content, merged.content)
assertEquals(ToolStatus.COMPLETED, merged.toolStatus)
assertEquals("terminal", merged.toolName)
assertEquals("call-42", merged.toolCallId)
assertEquals(cached.tokenCount, merged.tokenCount)
assertEquals(cached.tps, merged.tps)
assertEquals(cached.finishTimestamp, merged.finishTimestamp)
}
@Test
fun canonicalResultSettlesHistoricalRunningToolWithServerOutput() {
val cached =
ChatMessage(
id = "old-tool",
role = MessageRole.TOOL,
content = """{"name":"terminal","args":{"command":"pwd"}}""",
toolName = "terminal",
toolCallId = "call-42",
toolStatus = ToolStatus.RUNNING,
isHistoricalCache = true,
)
val page = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(cached))
val merged = mergeTranscriptWithLive(page, listOf(cached), preserveLiveIds = true).single()
assertEquals(cached.id, merged.id)
assertEquals("rest-s-42", merged.canonicalRestId)
assertEquals(ToolStatus.COMPLETED, merged.toolStatus)
assertTrue(merged.content.contains("exit_code"))
assertTrue(!merged.isHistoricalCache)
}
@Test
fun confirmedLiveOccurrenceCannotConsumeEarlierIdenticalPage() {
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) {
@@ -598,6 +655,50 @@ class ChatPagingMergeTest {
assertTrue(sameLogicalMessage(canonical, live))
}
@Test
fun verifierFooterReplyMatchesCanonicalCounterpartWithoutFooter() {
val footer =
"""
⚠️ File-mutation verifier: 1 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run git status or read_file to confirm what actually landed.
• /tmp/skill_lang_audit.py — [write_file] Write denied: '/tmp/skill_lang_audit.py' is outside HERMES_WRITE_SAFE_ROOT (/opt/data). Unset the variable or add this path's directory prefix.
""".trimIndent()
val body = "I completed the audit. Here are the findings."
val liveAssistant =
ChatMessage(
id = "uuid-123",
role = MessageRole.ASSISTANT,
content = "$body\n\n$footer",
completionId = "comp-1",
)
val serverRow =
SessionMessage(
id = 42,
role = "assistant",
content = JsonPrimitive(body),
)
// 1. mapServerMessages correctly matches liveAssistant and acquires comp-1 and rich footer content
val mapped = mapServerMessages("session", listOf(serverRow), 0, true, listOf(liveAssistant))
assertEquals(1, mapped.size)
val canonical = mapped.single()
assertEquals("rest-session-42", canonical.id)
assertEquals("comp-1", canonical.completionId)
assertTrue("Canonical mapped row should retain verifier footer", canonical.content.contains(footer))
// 2. mergeTranscriptWithLive merges into a single message preserving footer and acquiring restId
val merged =
mergeTranscriptWithLive(
restMessages = mapped,
currentMessages = listOf(liveAssistant),
preserveLiveIds = true,
)
assertEquals(1, merged.size)
val single = merged.single()
assertEquals("uuid-123", single.id)
assertEquals("rest-session-42", single.restId)
assertTrue("Merged message must keep the verifier warning footer", single.content.contains(footer))
}
@Test
fun permanentlyLocalRowsDoNotMatchCanonicalRowsByContent() {
val localFeedback =
@@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test
@@ -106,6 +107,23 @@ class ChatPersistenceRepositoryTest {
assertEquals(1000L, entity.timestamp)
}
@Test
fun deleteMessageRemovesOnlyUnconfirmedOptimisticRows() =
runTest {
repository.persistMessage(ChatMessage(id = "local", role = MessageRole.USER, content = "draft"), "s")
repository.persistMessage(
ChatMessage(id = "confirmed", role = MessageRole.USER, content = "history", restId = "rest-s-7"),
"s",
)
repository.deleteMessage("local")
repository.deleteMessage("confirmed")
val rows = repository.loadMessages("s")
assertFalse(rows.any { it.id == "local" })
assertEquals("confirmed", rows.single().id)
}
@Test
fun persistMessages_upsertsAllToDao() =
runTest {
@@ -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 com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.model.ActionResponse
import com.m57.hermescontrol.data.remote.ApiClient
@@ -76,6 +77,14 @@ class ChatUpdateCommandTest {
every { android.util.Log.e(any(), any(), any()) } returns 0
mockkObject(AuthManager)
// ChatModelSwitchDelegate.preloadModelOptions() reads pinned models
// when the catalog load succeeds; without this stub the spy falls
// through to the real AuthManager and throws "not initialized".
every { AuthManager.getPinnedModels() } returns emptyList()
// The delegate and the shared catalog store both collect this flow;
// park them on a never-emitting state so a later real-AuthManager
// emission cannot resume a stale Main-dispatched collector.
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
mockkObject(HermesWsClient)
mockkObject(ApiClient)
mockkObject(HermesDatabase)
@@ -87,6 +96,10 @@ class ChatUpdateCommandTest {
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.getPinnedModels() } returns emptyList()
every { AuthManager.getBaseUrl() } returns "http://test.local/"
every { AuthManager.getSelectedProfileId() } returns null
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false
@@ -148,7 +161,15 @@ class ChatUpdateCommandTest {
// threads — with a relaxed-mock app that NPEs on cacheDir and leaks
// an uncaught exception into the NEXT runTest class (CI flakes:
// UncaughtExceptionsBeforeTest).
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -185,7 +206,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate triggers the REST action and starts the progress popup`() =
runTest {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
coEvery { mockApi.updateHermes() } returns
Response.success(ActionResponse(ok = true, name = "hermes-update"))
// One running poll, then the action exits — runTest's teardown
@@ -224,7 +253,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate settles on success when the action exits cleanly`() =
runTest {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
coEvery { mockApi.updateHermes() } returns
Response.success(ActionResponse(ok = true, name = "hermes-update"))
coEvery { mockApi.getActionStatus("hermes-update") } returns
@@ -252,7 +289,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate surfaces a rejected trigger in the popup`() =
runTest {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
coEvery { mockApi.updateHermes() } returns
Response.error(404, "no update endpoint".toResponseBody())
@@ -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())
@Test
fun historicalRunningToolDoesNotClaimLiveActivity() {
val messages =
listOf(
msg("user", MessageRole.USER),
msg("old-tool", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING)
.copy(toolName = "terminal", isHistoricalCache = true),
)
assertEquals(AgentStatus.Typing, deriveAgentStatus(true, StreamingState(), messages))
assertEquals(
AgentStatus.Tool("terminal"),
deriveAgentStatus(true, StreamingState(), messages.map { it.copy(isHistoricalCache = false) }),
)
}
@Test
fun `empty list produces no turns`() {
assertEquals(emptyList<ChatTurn>(), groupIntoTurns(emptyList()))
@@ -568,6 +568,64 @@ class MarkdownTextFeatureTest {
assertEquals("Run val x = 1 here", parsed.toString())
}
@Test
fun testNestedInlineStyles_preserveCodeAndEmphasisFormatting() {
val parsed = parseInline("**bold `code` and *italic***", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("bold code and italic", parsed.toString())
assertTrue(parsed.spanStyles.any { it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold })
assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
assertTrue(parsed.spanStyles.any { it.item.fontStyle == androidx.compose.ui.text.font.FontStyle.Italic })
val codeStart = parsed.indexOf("code")
assertTrue(
parsed.spanStyles.any {
it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold &&
it.start <= codeStart &&
it.end >= codeStart + 4
},
)
assertTrue(
parsed.spanStyles.any {
it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace &&
it.start <= codeStart &&
it.end >= codeStart + 4
},
)
}
@Test
fun testLinkLabel_nestedEmphasisAndCode() {
val parsed =
parseInline(
"[**bold `code`**](https://example.com)",
Color.Black,
"",
false,
Color.Blue,
DEFAULT_HIGHLIGHTS,
)
assertEquals("bold code", parsed.toString())
assertTrue(parsed.getLinkAnnotations(0, parsed.length).isNotEmpty())
assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
}
@Test
fun testCodeSpan_doesNotParseInnerEmphasis() {
val parsed = parseInline("`**literal**`", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("**literal**", parsed.toString())
assertFalse(parsed.spanStyles.any { it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold })
}
@Test
fun testInlineCode_matchesSameLengthBacktickRunAndPreservesUnmatchedRun() {
val parsed = parseInline("``a ` tick`` and `unfinished", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("a ` tick and `unfinished", parsed.toString())
assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
}
@Test
fun testArabicStartingWithEnglishWord_wrapsEnglishInLtrIsolate() {
val input = "Okay سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو"
@@ -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 com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.model.PaginationInfo
import com.m57.hermescontrol.data.model.SessionMessagesResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
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.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
@@ -74,6 +78,14 @@ class SlashCommandDispatchRpcTest {
// COMMAND_DISPATCH captures in suite order). setMain alone is safe —
// the slash-dispatch send path is synchronous (no IO hop).
mockkObject(AuthManager)
// ChatModelSwitchDelegate.preloadModelOptions() reads pinned models
// when the catalog load succeeds; without this stub the spy falls
// through to the real AuthManager and throws "not initialized".
every { AuthManager.getPinnedModels() } returns emptyList()
// The delegate and the shared catalog store both collect this flow;
// park them on a never-emitting state so a later real-AuthManager
// emission cannot resume a stale Main-dispatched collector.
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
mockkObject(HermesWsClient)
mockkObject(ApiClient)
mockkObject(HermesDatabase)
@@ -94,6 +106,10 @@ class SlashCommandDispatchRpcTest {
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.getPinnedModels() } returns emptyList()
every { AuthManager.getBaseUrl() } returns "http://test.local/"
every { AuthManager.getSelectedProfileId() } returns null
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false
@@ -152,7 +168,15 @@ class SlashCommandDispatchRpcTest {
}
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()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -444,7 +468,11 @@ class SlashCommandDispatchRpcTest {
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
val m = methodCalls.last()
// Read the method from THIS invocation, not from the shared
// capture list: a concurrent request() landing between capture
// and answer can leave .last() pointing at another call (CI:
// "Value not yet captured" for the submitted prompt).
val m = arg<String>(0)
val d = CompletableDeferred<Any?>()
if (m == WsMethods.COMMAND_DISPATCH) {
// Backend rejects /status with the registry-miss 4018 (issue #576).
@@ -714,7 +742,9 @@ class SlashCommandDispatchRpcTest {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
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.
d.complete(mapOf("type" to "send", "message" to "Scan this repo and write AGENTS.md"))
} else {
@@ -730,6 +760,15 @@ class SlashCommandDispatchRpcTest {
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
coEvery {
ApiClient.hermesApi.getSessionMessages("session-xyz", any(), any(), any(), any())
} returns
retrofit2.Response.success(
SessionMessagesResponse(
messages = emptyList(),
pagination = PaginationInfo(order = "latest"),
),
)
vm.sendMessage("/init")
advanceUntilIdle()
@@ -83,6 +83,10 @@ class FakeChatMessageDao : ChatMessageDao {
messages.values.removeAll { it.sessionId == sessionId }
}
override suspend fun deleteUnconfirmedMessage(id: String) {
if (messages[id]?.restId == null) messages.remove(id)
}
/** Direct access for test setup — bypasses the suspend modifier. */
fun addMessageDirect(message: ChatMessageEntity) {
val existing = messages[message.id]
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.settings
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.theme.ThemePreference
@@ -47,6 +48,17 @@ class SettingsViewModelTest {
return vm
}
@Test
fun busySendDefaultLoadsAndSaves() {
every { AuthManager.getBusySendMode() } returns BusySendMode.GUIDE
val viewModel = createViewModel()
assertEquals(BusySendMode.GUIDE, viewModel.uiState.value.busySendMode)
viewModel.onBusySendModeChange(BusySendMode.QUEUE)
assertEquals(BusySendMode.QUEUE, viewModel.uiState.value.busySendMode)
verify(exactly = 1) { AuthManager.setBusySendMode(BusySendMode.QUEUE) }
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
@@ -71,6 +83,8 @@ class SettingsViewModelTest {
every { AuthManager.isUseDynamicColors() } returns true
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.setBusySendMode(any()) } returns Unit
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatFontScale() } returns 1.0f
every { AuthManager.isMessageStatsEnabled() } returns false
+2 -2
View File
@@ -14,7 +14,7 @@ coroutines = "1.11.0"
junit = "4.13.2"
junit5 = "6.1.3"
junitPlatform = "6.1.3"
kotlin = "2.4.10"
kotlin = "2.4.20"
# standalone per KSP 2.0+ guidance — NOT kotlinVersion-kspVersion; easy
# to miss during Kotlin upgrades since it no longer tracks the Kotlin version.
ksp = "2.3.12"
@@ -30,7 +30,7 @@ coil = "3.6.3"
room = "3.0.2"
sqlcipher = "4.19.0"
datastore = "1.1.2"
latex = "1.5.5"
latex = "1.5.6"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }