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:
85 files changed
+9284
-465
No files matched your search
@@ -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
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
+81
-1
@@ -41,7 +41,7 @@ class ChatMessagePagingDeviceTest {
|
||||
Room
|
||||
.databaseBuilder(context, HermesDatabase::class.java, databaseName)
|
||||
.setDriver(driver())
|
||||
.addMigrations(HermesDatabase.MIGRATION_8_9)
|
||||
.addMigrations(HermesDatabase.MIGRATION_8_9, HermesDatabase.MIGRATION_9_10)
|
||||
.build()
|
||||
repository = ChatPersistenceRepository(database.chatMessageDao())
|
||||
}
|
||||
@@ -234,6 +234,29 @@ class ChatMessagePagingDeviceTest {
|
||||
assertPagingIndexUsed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migration9to10PreservesAmbiguousUnsentUuidUserAsUnknown() =
|
||||
runBlocking {
|
||||
val unsent =
|
||||
ChatMessageEntity(
|
||||
id = "pending-before-upgrade",
|
||||
sessionId = "session",
|
||||
role = "USER",
|
||||
content = "may not have reached the server",
|
||||
timestamp = 1L,
|
||||
sortOrder = 1L,
|
||||
)
|
||||
createVersion9(listOf(unsent))
|
||||
openDatabase()
|
||||
val restored = database.chatMessageDao().getMessage(unsent.id)!!
|
||||
assertEquals("UNKNOWN", restored.messageProvenance)
|
||||
assertEquals(
|
||||
com.m57.hermescontrol.ui.chat.MessageProvenance.UNKNOWN,
|
||||
restored.toUiModel().messageProvenance,
|
||||
)
|
||||
assertEquals(unsent.content, restored.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmedAliasPreservesLivePayloadAndCanonicalOrderAcrossReopen() =
|
||||
runBlocking {
|
||||
@@ -350,6 +373,63 @@ class ChatMessagePagingDeviceTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the exported v9 schema so Room validates the real non-destructive v9 -> v10 path. */
|
||||
private fun createVersion9(rows: List<ChatMessageEntity>) {
|
||||
database.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
val schema =
|
||||
InstrumentationRegistry
|
||||
.getInstrumentation()
|
||||
.context.assets
|
||||
.open("com.m57.hermescontrol.data.local.HermesDatabase/9.json")
|
||||
.bufferedReader()
|
||||
.use { JSONObject(it.readText()).getJSONObject("database") }
|
||||
val file = context.getDatabasePath(databaseName)
|
||||
file.parentFile?.mkdirs()
|
||||
driver().open(file.absolutePath).use { connection ->
|
||||
val entity = schema.getJSONArray("entities").getJSONObject(0)
|
||||
connection.execSQL(entity.getString("createSql").replace("\${TABLE_NAME}", "chat_messages"))
|
||||
val indices = entity.getJSONArray("indices")
|
||||
for (index in 0 until indices.length()) {
|
||||
connection.execSQL(
|
||||
indices.getJSONObject(index).getString("createSql").replace("\${TABLE_NAME}", "chat_messages"),
|
||||
)
|
||||
}
|
||||
val setup = schema.getJSONArray("setupQueries")
|
||||
for (index in 0 until setup.length()) connection.execSQL(setup.getString(index))
|
||||
connection
|
||||
.prepare(
|
||||
"INSERT INTO chat_messages (id, session_id, role, content, reasoning_text, timestamp, " +
|
||||
"tool_name, tool_call_id, tool_status, is_streaming, display_kind, token_count, tps, " +
|
||||
"completion_id, rest_id, sort_group, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).use { insert ->
|
||||
rows.forEach { row ->
|
||||
insert.bindText(1, row.id)
|
||||
insert.bindText(2, row.sessionId)
|
||||
insert.bindText(3, row.role)
|
||||
insert.bindText(4, row.content)
|
||||
insert.bindText(5, row.reasoningText)
|
||||
insert.bindLong(6, row.timestamp)
|
||||
row.toolName?.let { insert.bindText(7, it) } ?: insert.bindNull(7)
|
||||
insert.bindText(8, row.toolCallId)
|
||||
row.toolStatus?.let { insert.bindText(9, it) } ?: insert.bindNull(9)
|
||||
insert.bindLong(10, if (row.isStreaming) 1L else 0L)
|
||||
row.displayKind?.let { insert.bindText(11, it) } ?: insert.bindNull(11)
|
||||
row.tokenCount?.let { insert.bindLong(12, it.toLong()) } ?: insert.bindNull(12)
|
||||
row.tps?.let { insert.bindDouble(13, it) } ?: insert.bindNull(13)
|
||||
row.completionId?.let { insert.bindText(14, it) } ?: insert.bindNull(14)
|
||||
row.restId?.let { insert.bindText(15, it) } ?: insert.bindNull(15)
|
||||
insert.bindLong(16, row.sortGroup.toLong())
|
||||
insert.bindLong(17, row.sortOrder)
|
||||
insert.step()
|
||||
insert.reset()
|
||||
insert.clearBindings()
|
||||
}
|
||||
}
|
||||
connection.execSQL("PRAGMA user_version = 9")
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertPagingIndexUsed() {
|
||||
database.close()
|
||||
driver().open(context.getDatabasePath(databaseName).absolutePath).use { connection ->
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
+33
@@ -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()
|
||||
}
|
||||
}
|
||||
+165
-6
@@ -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()
|
||||
|
||||
+8
@@ -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")
|
||||
}
|
||||
|
||||
+98
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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()
|
||||
}
|
||||
}
|
||||
+139
@@ -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()
|
||||
}
|
||||
}
|
||||
+63
-18
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in new issue
Block a user