diff --git a/README.md b/README.md index 8fd98e94..76b4aefe 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,29 @@ Tap **Sign in** on the landing screen and enter the dashboard host and port. The | **Token only** | Dashboard on same machine (loopback) | **Token** — grab from `~/.hermes/dashboard-token.txt` or `~/.hermes/.env` (`HERMES_DASHBOARD_SESSION_TOKEN`). The app can also auto-extract it from the dashboard page | | **Basic auth** | Dashboard on LAN with password gate | **Username** + **Password** (default `admin` / `hermes`). The app logs in, gets a session cookie, and mints a WebSocket ticket automatically | -> The app communicates over plain HTTP — it's designed for **trusted local networks only**. Do not expose your Hermes gateway to untrusted networks. +Use HTTPS for remote connections. Use HTTP only on a trusted local network. + +### Cloudflare Access and custom headers + +1. Enter your dashboard's HTTPS URL on the login screen. +2. Tap **Custom headers**, then **Add Cloudflare headers**. +3. Enter your service token's `CF-Access-Client-Id` and `CF-Access-Client-Secret` values. +4. Tap **Save**. The app probes the dashboard again, then shows the Hermes login fields. + +Your [Cloudflare Access policy](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/) +must accept the service token. These headers authenticate with Cloudflare; you still +need to complete Hermes authentication. + +You can add other custom header names and values in the same editor. To edit or +remove saved headers, open **Custom headers** from login or the saved connection's +edit dialog in Settings. Values are masked and stored in encrypted preferences. +Saving an empty list removes the headers for that URL. + +Headers are shared by profiles with the same server URL. They apply to probes, +login, API and media requests, and WebSocket handshakes. Each URL's scheme, host, +port, and path prefix limit where its headers are sent. Redirects outside that +scope do not receive the credentials; WebSocket redirects are not followed. +The app manages `Authorization`, `Cookie`, and transport headers itself. ### Connection profiles diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt index 5e5c5b79..52dd188f 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt @@ -10,13 +10,11 @@ import androidx.core.content.ContextCompat import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.data.model.Attachment -import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.ui.common.ActionProgressController import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic -import io.mockk.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -128,37 +126,4 @@ class ChatScreenTest { composeTestRule.onNodeWithTag("chat_input").assertIsDisplayed() composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() } - - @Test - fun chatScreen_primarySendUsesSavedBusyDefault() { - val uiState = - ChatUiState( - connectionStatus = ConnectionStatus.CONNECTED, - isSessionReady = true, - busySendMode = BusySendMode.QUEUE, - ) - val mockViewModel = mockk(relaxed = true) - every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow() - every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow() - every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow() - every { mockViewModel.connectionOperationState } returns - MutableStateFlow(ConnectionOperationUiState()).asStateFlow() - every { mockViewModel.actionProgress } returns - ActionProgressController(scope = CoroutineScope(Dispatchers.Main)) - every { mockViewModel.sendMessage(any(), any()) } returns true - - composeTestRule.setContent { - ChatScreen( - onOpenDrawer = {}, - sessionId = null, - viewModel = mockViewModel, - ) - } - - composeTestRule.onNodeWithTag("chat_input").performTextInput("Use my saved default") - composeTestRule.onNodeWithTag("send_button").performClick() - - verify(exactly = 1) { mockViewModel.sendMessage("Use my saved default", null) } - verify(exactly = 0) { mockViewModel.sendMessage("Use my saved default", BusySendMode.CORRECT) } - } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt index 63317410..cdda65ee 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt @@ -32,7 +32,6 @@ import androidx.test.filters.MediumTest import androidx.test.platform.app.InstrumentationRegistry import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment -import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.ws.CommandCatalog import org.junit.Assert.assertEquals import org.junit.Rule @@ -58,158 +57,6 @@ class ComposerInteractionTest { private var photosTaps = 0 private var fileTaps = 0 - private fun setContextualComposer( - text: String, - isMainTurnBusy: Boolean, - attachments: List = emptyList(), - onSend: () -> Unit = {}, - onBusySend: (BusySendMode) -> Unit = {}, - isConnected: Boolean = true, - pendingReasoningLevel: String? = null, - ) { - composeTestRule.setContent { - ChatInputBar( - inputFieldValue = TextFieldValue(text), - onInputChange = {}, - onSend = onSend, - onBusySend = onBusySend, - onMicTap = {}, - isListening = false, - isAgentTyping = isMainTurnBusy, - isMainTurnBusy = isMainTurnBusy, - isConnected = isConnected, - commandCatalog = CommandCatalog(), - isSessionReady = true, - pendingAttachments = attachments, - pendingReasoningLevel = pendingReasoningLevel, - ) - } - } - - @Test - fun idleDraft_showsRegularSendWithoutBusyActions() { - setContextualComposer(text = "hello", isMainTurnBusy = false) - - composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() - composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist() - } - - @Test - fun busyBlankDraft_hidesBusyActions() { - setContextualComposer(text = "", isMainTurnBusy = true) - - composeTestRule.onNodeWithTag("send_button").assertDoesNotExist() - composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist() - } - - @Test - fun busyTextDraft_showsSendAndQueueAndRoutesCallbacks() { - var regularSends = 0 - val selected = mutableListOf() - setContextualComposer( - text = "change course", - isMainTurnBusy = true, - onSend = { regularSends++ }, - onBusySend = selected::add, - ) - - composeTestRule.onNodeWithTag("send_button").assertIsDisplayed().performClick() - composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed().performClick() - - composeTestRule.runOnIdle { - assertEquals(1, regularSends) - assertEquals(listOf(BusySendMode.QUEUE), selected) - } - } - - @Test - fun busyAttachmentOnly_showsSendAndQueue() { - val attachment = Attachment("content://document/1", "notes.txt", "text/plain") - setContextualComposer(text = "", isMainTurnBusy = true, attachments = listOf(attachment)) - - composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() - composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed() - } - - @Test - fun busyDisconnectedDraft_disablesSendAndQueue() { - setContextualComposer(text = "hello", isMainTurnBusy = true, isConnected = false) - composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled() - composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled() - } - - @Test - fun busyPendingReasoning_disablesSendAndQueue() { - setContextualComposer(text = "hello", isMainTurnBusy = true, pendingReasoningLevel = "high") - composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled() - composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled() - } - - @Test - fun busySlashDraft_usesRegularSendWithoutBusyActions() { - var regularSends = 0 - setContextualComposer( - text = "/stop", - isMainTurnBusy = true, - onSend = { regularSends++ }, - ) - - composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist() - composeTestRule.onNodeWithTag("send_button").performClick() - composeTestRule.runOnIdle { assertEquals(1, regularSends) } - } - - @Test - fun busyBecomesIdle_hidesQueueAndPreservesTypedDraft() { - val busy = mutableStateOf(true) - composeTestRule.setContent { - var input by remember { mutableStateOf(TextFieldValue("draft in progress")) } - ChatInputBar( - inputFieldValue = input, - onInputChange = { input = it }, - onSend = {}, - onBusySend = {}, - onMicTap = {}, - isListening = false, - isAgentTyping = busy.value, - isMainTurnBusy = busy.value, - isConnected = true, - commandCatalog = CommandCatalog(), - isSessionReady = true, - ) - } - - composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed() - composeTestRule.runOnUiThread { busy.value = false } - composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist() - composeTestRule.onNodeWithTag("chat_input").assertTextEquals("draft in progress") - composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() - } - - @Test - fun busyDraft_narrowWidthKeepsSendAndQueueAccessible() { - composeTestRule.setContent { - Box(Modifier.width(220.dp)) { - ChatInputBar( - inputFieldValue = TextFieldValue("change course"), - onInputChange = {}, - onSend = {}, - onBusySend = {}, - onMicTap = {}, - isListening = false, - isAgentTyping = true, - isMainTurnBusy = true, - isConnected = true, - commandCatalog = CommandCatalog(), - isSessionReady = true, - ) - } - } - - composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed() - composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() - } - /** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */ private fun setComposer( reasoningLevel: String? = "medium", diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt index d8f4ab7b..daee1c97 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt @@ -1,5 +1,8 @@ package com.m57.hermescontrol.ui.chat.components +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled @@ -11,6 +14,9 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.CatalogScanStatus +import com.m57.hermescontrol.data.model.ConnectionCatalogInfo +import com.m57.hermescontrol.data.model.ConnectionCatalogScan import com.m57.hermescontrol.data.model.ConnectionEnvField import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot import com.m57.hermescontrol.data.model.ConnectionOperationTarget @@ -212,6 +218,115 @@ class ConnectionSetupSheetTest { composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist() } + @Test + fun pluginTarget_showsSourcePinScanAndRequirements_andApprovesWithoutSecrets() { + var response: Triple, Boolean>? = null + composeTestRule.setContent { + ConnectionSetupContent( + state = state(pluginTarget()), + onRespond = { name, values, approved -> response = Triple(name, values, approved) }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithText("Install Linear").assertIsDisplayed() + composeTestRule + .onNodeWithTag( + "connection_catalog_source", + ).assertTextContains("NousResearch/hermes-plugins/linear", substring = true) + composeTestRule.onNodeWithTag("connection_catalog_pin").assertTextContains("0123456789ab", substring = true) + composeTestRule + .onNodeWithTag("connection_catalog_scan") + .assertTextContains("Security scan found warnings · 1 advisory") + composeTestRule + .onNodeWithTag( + "connection_catalog_requirements", + ).assertTextContains("Hermes >=0.21", substring = true) + composeTestRule.onNodeWithTag("connection_catalog_profile").assertTextContains("default", substring = true) + composeTestRule.onNodeWithText("This setup step is not supported by this app version.").assertDoesNotExist() + + composeTestRule.onNodeWithTag("connection_setup_connect").assertTextContains("Install").performClick() + + assertEquals("linear", response?.first) + assertEquals(mapOf("LINEAR_API_KEY" to ""), response?.second) + assertEquals(true, response?.third) + } + + @Test + fun pluginTarget_installingAndFailedStates() { + var target = pluginTarget(state = ConnectionTargetState.INITIATED) + var current by mutableStateOf(state(target)) + composeTestRule.setContent { + ConnectionSetupContent( + state = current, + onRespond = { _, _, _ -> }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithTag("connection_catalog_installing").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_connect").assertDoesNotExist() + + target = pluginTarget(state = ConnectionTargetState.FAILED, detail = "blocked by the kill list") + current = state(target) + composeTestRule.onNodeWithText("blocked by the kill list").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_connect").assertTextContains("Try again") + } + + @Test + fun installedSkill_reportsTheLoadableSkill() { + composeTestRule.setContent { + ConnectionSetupContent( + state = + state( + pluginTarget(state = ConnectionTargetState.CONNECTED).copy( + kind = ConnectionTargetKind.SKILL, + catalog = pluginCatalog().copy(display = "Review", skill = "review", scan = null), + ), + ), + onRespond = { _, _, _ -> }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithText("Skill available: review").assertIsDisplayed() + composeTestRule.onNodeWithText("No security scan reported").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist() + } + + private fun pluginCatalog(): ConnectionCatalogInfo = + ConnectionCatalogInfo( + display = "Linear", + description = "Linear issues.", + tier = "official", + platforms = emptyList(), + repo = "NousResearch/hermes-plugins", + sha = "0123456789abcdef0123456789abcdef01234567", + subdir = "linear", + scan = ConnectionCatalogScan(CatalogScanStatus.WARNINGS, "1 advisory"), + requirements = listOf("Hermes >=0.21"), + targetProfile = "default", + skill = null, + ) + + private fun pluginTarget( + state: ConnectionTargetState = ConnectionTargetState.PENDING, + detail: String? = null, + ): ConnectionOperationTarget = + target( + name = "linear", + state = state, + requiredEnv = listOf(env("LINEAR_API_KEY", required = false, secret = true)), + ).copy( + kind = ConnectionTargetKind.PLUGIN, + action = ConnectionTargetAction.INSTALL, + detail = detail, + catalog = pluginCatalog(), + ) + private fun state(target: ConnectionOperationTarget): ConnectionOperationUiState = ConnectionOperationUiState(operation = operation(target)) diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt deleted file mode 100644 index ab69dd29..00000000 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.m57.hermescontrol.ui.chat.components - -import androidx.activity.ComponentActivity -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.m57.hermescontrol.R -import com.m57.hermescontrol.data.model.BusySendMode -import com.m57.hermescontrol.ui.chat.PendingSend -import com.m57.hermescontrol.ui.chat.PendingSendState -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class PendingSendPanelTest { - @get:Rule - val compose = createAndroidComposeRule() - - private fun row(state: PendingSendState) = - PendingSend("receipt", "scope", "session", "My message", mode = BusySendMode.CORRECT, state = state) - - @Test - fun ordinarySendNeverShowsDeliveryPanel() { - val state = mutableStateOf(PendingSendState.SENDING) - compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) } - compose.onNodeWithText("My message").assertDoesNotExist() - compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_sends)).assertDoesNotExist() - compose.runOnIdle { state.value = PendingSendState.ACCEPTED } - compose.onNodeWithText("My message").assertDoesNotExist() - compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_accepted)).assertDoesNotExist() - } - - @Test - fun queueDisappearsWhenDispatchedWithoutAwaitingLabel() { - val state = mutableStateOf(PendingSendState.QUEUED) - compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) } - compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_queued)).assertIsDisplayed() - compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertDoesNotExist() - compose.runOnIdle { state.value = PendingSendState.SENDING } - compose.onNodeWithText("My message").assertDoesNotExist() - compose.runOnIdle { state.value = PendingSendState.ACCEPTED } - compose.onNodeWithText("My message").assertDoesNotExist() - } - - @Test - fun failedAndUncertainDeliveryRemainRecoverable() { - val state = mutableStateOf(PendingSendState.REJECTED) - var retried: String? = null - compose.setContent { - PendingSendPanel( - listOf(row(state.value)), - mainTurnBusy = false, - onSendNow = { retried = it }, - ) - } - compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_rejected)).assertIsDisplayed() - compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).performClick() - compose.runOnIdle { - assertEquals("receipt", retried) - state.value = PendingSendState.UNKNOWN - } - compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_unknown)).assertIsDisplayed() - compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertIsDisplayed() - } - - @Test - fun busyPanelWarnsAndLabelsEveryRecoverableActionAsStopAndSend() { - val state = mutableStateOf(PendingSendState.QUEUED) - compose.setContent { - PendingSendPanel( - sends = listOf(row(state.value)), - mainTurnBusy = true, - onSendNow = {}, - ) - } - - listOf( - PendingSendState.QUEUED, - PendingSendState.PARKED, - PendingSendState.REJECTED, - PendingSendState.UNKNOWN, - ).forEach { pendingState -> - compose.runOnIdle { state.value = pendingState } - compose - .onNodeWithText( - compose.activity.getString(R.string.chat_pending_send_now_warning), - ).assertIsDisplayed() - compose.onNodeWithText(compose.activity.getString(R.string.chat_busy_stop_and_send)).assertIsDisplayed() - compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).assertDoesNotExist() - compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_again)).assertDoesNotExist() - } - } -} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt new file mode 100644 index 00000000..5c9a80c1 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt @@ -0,0 +1,294 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.Context +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.text.input.TextFieldValue +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.ws.CommandCatalog +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * Gesture-level coverage for the Telegram-style voice-note mic: a held press + * arms the recorder, the release submits exactly once, sliding left cancels, + * sliding up locks hands-free, and the shared click handlers must never + * re-dispatch the dictation (mic tap) action for a hold. + * + * Regression: the action button's click handler fires on ANY stationary + * release — including one that ended a hold — so letting go of a voice note + * also launched the system speech-recognizer intent. The gesture loop now + * consumes the hold phase, and these tests pin that behavior down. + * + * The hold threshold runs on the compose test clock, so the tests advance + * [createAndroidComposeRule]'s mainClock instead of sleeping. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class VoiceNoteGestureTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private var micTaps = 0 + private var holdStarts = 0 + private var holdEnds = 0 + private var holdCancels = 0 + private var sendCount = 0 + private var locks = 0 + + /** + * Renders the real input bar with the launcher's contract: hold start/end + * flip the recording state, the lock callback flips the locked flag, and a + * cancel releases both — exactly like ChatMediaLaunchers does. + */ + private fun setComposer( + initiallyLocked: Boolean = false, + draft: String = "", + ) { + // The lock-hint tooltip hides after the first successful lock and + // persists that; start every run from a clean slate so the rendered + // UI is deterministic. + composeTestRule.activity + .getSharedPreferences("chat_voice_prefs", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + composeTestRule.setContent { + var locked by remember { mutableStateOf(initiallyLocked) } + var recording by remember { mutableStateOf(initiallyLocked) } + // Bottom-anchor the bar like the real chat screen, so floating + // overlays above the card render inside the captured frame. + Column(modifier = Modifier.fillMaxSize()) { + Spacer(modifier = Modifier.weight(1f)) + ChatInputBar( + inputFieldValue = TextFieldValue(draft), + onInputChange = {}, + onSend = { sendCount++ }, + onMicTap = { micTaps++ }, + isListening = false, + isAgentTyping = false, + isConnected = true, + commandCatalog = CommandCatalog(), + isSessionReady = true, + onMicHoldStart = { + holdStarts++ + recording = true + }, + onMicHoldEnd = { + holdEnds++ + recording = false + }, + onMicHoldCancel = { + holdCancels++ + locked = false + recording = false + }, + onMicLock = { + locks++ + locked = true + }, + isVoiceNoteLocked = locked, + isRecordingVoice = recording, + ) + } + } + } + + @Test + fun stationaryHoldRelease_sendsWithoutTriggeringMicTap() { + setComposer() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + // Mid-hold capture: the recording strip must be visible even though + // the mic button sits under the finger (device follow-up, #1247). + val shot = composeTestRule.onRoot().captureToImage().asAndroidBitmap() + File(composeTestRule.activity.getExternalFilesDir(null), "voice_note_recording.png") + .outputStream() + .use { shot.compress(Bitmap.CompressFormat.PNG, 100, it) } + // The lock hint must exist while holding (it sits above the card). + composeTestRule.onNodeWithText("Slide up to lock recording").assertExists() + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("release must submit the note", 1, holdEnds) + assertEquals( + "a held release must not re-dispatch the tap action", + 0, + micTaps, + ) + assertEquals("a submitted hold must not cancel", 0, holdCancels) + } + } + + @Test + fun quickTap_stillDispatchesMicTap() { + setComposer() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { + down(center) + up() + } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("a quick tap must start dictation", 1, micTaps) + assertEquals(0, holdStarts) + assertEquals(0, holdEnds) + } + } + + @Test + fun holdSlideAway_cancelsWithoutSending() { + setComposer() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { moveBy(Offset(-240f, 0f)) } + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("sliding left must cancel the recording", 1, holdCancels) + assertEquals("a cancelled hold must not submit", 0, holdEnds) + assertEquals("a cancelled hold must not start dictation", 0, micTaps) + } + } + + @Test + fun holdSlideUp_locksWithoutSending() { + setComposer() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { moveBy(Offset(0f, -240f)) } + // Lifting the finger after the slide-up lock: target the root, because + // the action slot has already morphed into the send button. + composeTestRule.onRoot().performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("sliding up must lock the recording", 1, locks) + assertEquals("a locked hold must not submit on release", 0, holdEnds) + assertEquals("a locked hold must not cancel", 0, holdCancels) + assertEquals("a locked hold must not start dictation", 0, micTaps) + } + + composeTestRule.onNodeWithTag("voice_note_send_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("voice_note_send_button").performClick() + composeTestRule.runOnIdle { + assertEquals("the locked send button must dispatch the mic action", 1, micTaps) + assertEquals("sending must not re-lock", 1, locks) + } + } + + @Test + fun holdRelease_returnsFocusToTheInput() { + setComposer() + + composeTestRule.onNodeWithTag("chat_input").performClick() + composeTestRule.onNodeWithTag("chat_input").assertIsFocused() + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + // The strip replaced the input field while recording; the send must + // hand focus back so the keyboard re-opens without an extra tap + // (device follow-up, #1247). + composeTestRule.onNodeWithTag("chat_input").assertIsFocused() + } + + @Test + fun lockedRecording_showsCancelActionAndRoutesCancel() { + setComposer(initiallyLocked = true) + + composeTestRule.onNodeWithTag("voice_note_recording_panel").assertIsDisplayed() + composeTestRule.onNodeWithTag("voice_note_cancel_button").assertIsDisplayed() + // Accessibility (review, PR #1280): the locked CANCEL is a real + // button. Its hit area is expanded by minimumInteractiveComponentSize + // (Compose keeps the visual text compact and widens the touch target), + // so the assertion pins the button role rather than layout bounds. + composeTestRule + .onNodeWithTag("voice_note_cancel_button") + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) + + val shot = composeTestRule.onRoot().captureToImage().asAndroidBitmap() + val file = + File(composeTestRule.activity.getExternalFilesDir(null), "voice_note_locked.png") + file.outputStream().use { shot.compress(Bitmap.CompressFormat.PNG, 100, it) } + + composeTestRule.onNodeWithTag("voice_note_cancel_button").performClick() + composeTestRule.runOnIdle { + assertEquals("cancel must cancel the recording", 1, holdCancels) + } + } + + @Test + fun holdReleaseWithDraft_doesNotRouteTapToDictation() { + // With a draft present the secondary mic ("mic_button") appears next to + // the input. Review, PR #1280: its gesture modifier is chained after + // the button's own clickable (the reverse of the action button), so a + // stationary hold-release must not leak a finger-up into onMicTap. + setComposer(draft = "draft") + val tapsBefore = micTaps + + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.runOnIdle { + assertEquals("release must submit the note", 1, holdEnds) + assertEquals("a stationary hold-release must not fire a tap", tapsBefore, micTaps) + } + } + + @Test + fun lockedWithDraft_actionButtonFinishesTheVoiceNote() { + // Blocker, review, PR #1280: with a draft present the action button is + // a send arrow; once the recording is locked it must finish the voice + // note instead of sending the draft text. + setComposer(draft = "draft") + composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.onNodeWithTag("mic_button").performTouchInput { moveBy(Offset(0f, -240f)) } + composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("voice_note_send_button").performClick() + composeTestRule.runOnIdle { + assertEquals("the tap must finish the voice note", 1, micTaps) + assertEquals("the draft text must not be sent", 0, sendCount) + } + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/mcp/McpOwnershipCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/mcp/McpOwnershipCardTest.kt new file mode 100644 index 00000000..ddac625e --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/mcp/McpOwnershipCardTest.kt @@ -0,0 +1,77 @@ +package com.m57.hermescontrol.ui.mcp + +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.isToggleable +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.McpServer +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.mcp.components.ServerCard +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class McpOwnershipCardTest { + @get:Rule val composeRule = createComposeRule() + + @Test + fun pluginRowExplainsOwnershipAndDisablesMutations() { + showServer("plugin", "security") + composeRule.onNodeWithText("From plugin security · Read-only", useUnmergedTree = true).assertExists() + composeRule.onNode(isToggleable()).assertIsNotEnabled() + composeRule.onNodeWithContentDescription("Delete").assertIsNotEnabled() + composeRule.onNodeWithText("Authorize").assertIsNotEnabled() + composeRule.onNodeWithText("Test").assertIsEnabled() + composeRule.onNodeWithText("Env vars").performClick() + composeRule.onNodeWithContentDescription("Remove").assertIsNotEnabled() + composeRule.onNodeWithText("Add env var").assertIsNotEnabled() + composeRule.onNodeWithText("KEY", useUnmergedTree = true).assertExists() + } + + @Test + fun configRowKeepsMutationsEnabled() { + showServer("config", null) + composeRule.onNode(isToggleable()).assertIsEnabled() + composeRule.onNodeWithContentDescription("Delete").assertIsEnabled() + composeRule.onNodeWithText("Authorize").assertIsEnabled() + composeRule.onNodeWithText("Env vars").performClick() + composeRule.onNodeWithContentDescription("Remove").assertIsEnabled() + composeRule.onNodeWithText("Add env var").assertIsEnabled() + } + + private fun showServer( + source: String, + plugin: String?, + ) { + val server = + McpServer( + name = "scanner", + enabled = true, + source = source, + plugin = plugin, + auth = "oauth", + env = mapOf("KEY" to "test-value"), + ) + val viewModel = McpServersViewModel() + composeRule.setContent { + HermesControlTheme { + ServerCard( + server = server, + state = McpServersUiState(servers = listOf(server)), + viewModel = viewModel, + spacing = LocalSpacing.current, + onClick = {}, + onOpenBrowser = {}, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 7f863c92..42ec81c3 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -80,6 +80,8 @@ import kotlinx.serialization.Serializable @Serializable data object FilesScreen : NavKey +@Serializable data object AccountConnectorsScreen : NavKey + // ── Settings drill-down sub-pages ────────────────────────────────────── @Serializable data object SettingsConnection : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt index f812b30a..eadc0974 100644 --- a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt @@ -39,6 +39,7 @@ import com.m57.hermescontrol.ui.bots.BotsScreen as BotsScreenContent import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent +import com.m57.hermescontrol.ui.connectors.AccountConnectorsScreen as AccountConnectorsScreenContent import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent import com.m57.hermescontrol.ui.files.FilesScreen as FilesScreenContent import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent @@ -236,6 +237,12 @@ object ScreenRegistry { Icons.Filled.EmojiEvents, DrawerSection.INSPECT, ) { sessionId, openDrawer -> AchievementsScreenContent(onOpenDrawer = openDrawer) }, + ScreenDefinition( + AccountConnectorsScreen, + R.string.screen_account_connectors, + Icons.Filled.Extension, + DrawerSection.CONFIGURE, + ) { _, openDrawer -> AccountConnectorsScreenContent(onOpenDrawer = openDrawer) }, ScreenDefinition( SettingsScreen, R.string.screen_settings, diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index d8d0fc33..464b130c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -18,6 +18,7 @@ import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.CookieManager import com.m57.hermescontrol.data.remote.ServerEndpoint +import com.m57.hermescontrol.data.remote.ServerHeaders import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreset @@ -215,6 +216,7 @@ object AuthManager { EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) migrateLegacyDefaultIfNeeded(p) + ServerHeaders.initialize(p) synchronized(this@AuthManager) { readyPrefs = p cachedToken = getTokenInternal(p) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt index 064ca0b9..e5a9167c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt @@ -13,6 +13,7 @@ data class ConnectionOperationSnapshot( val settled: Boolean, val settledBy: String?, val targets: List, + val accountOwned: Boolean = false, ) data class ConnectionOperationTarget( @@ -29,6 +30,8 @@ data class ConnectionOperationTarget( val requiredEnv: List, val tools: List, val hint: String?, + /** Catalog facts for [ConnectionTargetKind.PLUGIN] / [ConnectionTargetKind.SKILL] rows only. */ + val catalog: ConnectionCatalogInfo? = null, ) { /** Safe validated browser URL. OAuth query parameters stay redacted from [toString]. */ val safeConnectUrl: String? @@ -40,9 +43,32 @@ data class ConnectionOperationTarget( "instructions=$instructions, discoveryError=$discoveryError, " + "connectUrl=${if (connectUrl == null) "null" else "[REDACTED]"}, " + "connectionId=$connectionId, attempt=$attempt, requiredEnv=$requiredEnv, " + - "tools=$tools, hint=$hint)" + "tools=$tools, hint=$hint, catalog=$catalog)" } +/** Host-resolved catalog row facts (hermes-agent `manage_catalog`); the model supplies only the id. */ +data class ConnectionCatalogInfo( + val display: String, + val description: String?, + val tier: String?, + val platforms: List, + val repo: String?, + val sha: String?, + val subdir: String?, + val scan: ConnectionCatalogScan?, + val requirements: List, + val targetProfile: String, + /** On an installed row: the skill the model can now load. */ + val skill: String?, +) + +data class ConnectionCatalogScan( + val status: CatalogScanStatus, + val summary: String?, +) + +enum class CatalogScanStatus { PASSED, WARNINGS, FAILED, UNKNOWN } + data class ConnectionEnvField( val name: String, val required: Boolean, @@ -51,7 +77,16 @@ data class ConnectionEnvField( val prompt: String?, ) -enum class ConnectionTargetKind { CONNECTOR, MCP, UNKNOWN } +enum class ConnectionTargetKind { + CONNECTOR, + MCP, + PLUGIN, + SKILL, + UNKNOWN, + ; + + val isCatalog: Boolean get() = this == PLUGIN || this == SKILL +} enum class ConnectionTargetAction { AUTHORIZE, diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt index 13d16db0..092af4aa 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt @@ -231,3 +231,70 @@ sealed interface ConnectorConnectResult { fun errorOrNull(): ConnectorError? = (this as? Error)?.error } + +/** Account-level connector records exposed outside the chat session lifecycle. */ +data class ConnectorAccount( + val connectionId: String, + val connector: String, + val status: String, + val statusReason: String?, + val label: String, + val alias: String?, + val active: Boolean, + val createdAt: String, + val updatedAt: String, +) + +data class ConnectorCatalogEntry( + val slug: String, + val name: String, + val description: String, + val category: String, +) + +data class ConnectorTool( + val slug: String, + val name: String, + val description: String, + val facet: String, + val deprecated: Boolean, + val hints: List = emptyList(), +) + +data class ConnectorPolicy( + val revision: String, + val mode: String, + val connectors: List, + val disabledConnectors: List, + val tools: Map>, + val enabledTags: List = emptyList(), + val disabledTags: List = emptyList(), + val member: ConnectorPolicy? = null, + val inherited: List = emptyList(), +) { + fun toolEnabled( + slug: String, + tool: ConnectorTool, + ): Boolean = + connectorEnabled(slug) && tool.slug !in tools[slug].orEmpty() && + disabledTags.none { it in tool.hints } && (enabledTags.isEmpty() || enabledTags.any { it in tool.hints }) + + fun connectorEnabled(slug: String): Boolean = + when (mode) { + "unrestricted" -> true + "deny-all" -> false + "allow" -> slug in connectors + "deny" -> slug !in disabledConnectors + else -> false + } +} + +sealed interface AccountConnectorResult { + data class Success( + val value: T, + ) : AccountConnectorResult + + data class Failure( + val error: ConnectorError, + ) : AccountConnectorResult +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt index 69ba7c59..9b671430 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt @@ -20,7 +20,12 @@ data class McpServer( val error: String? = null, val auth: String? = null, val tools: List? = null, -) + val source: String = "config", + val plugin: String? = null, +) { + // #1283: plugin-provided servers are managed by their owning plugin. + val isPluginOwned: Boolean get() = source == "plugin" +} @Serializable data class McpServerTestResponse( diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt index 1e00957e..b6f47a46 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt @@ -81,6 +81,26 @@ data class PluginsHubResponse( val providers: PluginsHubProviders? = null, ) +@Serializable +data class PluginUpdateRequest( + @SerialName("accept_capabilities") val acceptCapabilities: Boolean, +) + +@Serializable +data class PluginUpdateResult( + val ok: Boolean = false, + val unchanged: Boolean = false, + @SerialName("consent_required") val consentRequired: Boolean = false, + val delta: Map>? = null, + @SerialName("delta_lines") val deltaLines: List? = null, + val error: String? = null, +) { + val capabilityDelta: List + get() = + deltaLines?.takeIf { it.isNotEmpty() } + ?: delta.orEmpty().map { (surface, added) -> "$surface: ${added.joinToString() }" } +} + @Serializable data class AgentPluginInstallBody( val identifier: String = "", diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionCompressModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionCompressModels.kt new file mode 100644 index 00000000..8a9555db --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionCompressModels.kt @@ -0,0 +1,32 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class SessionCompressResponse( + val status: String? = null, + val removed: Int? = null, + val before_messages: Int? = null, + val after_messages: Int? = null, + val before_tokens: Long? = null, + val after_tokens: Long? = null, + val summary: CompressionSummary? = null, + val compressed: Boolean? = null, + val lock_held: Boolean? = null, + val message: String? = null, + val messages: List? = null, + val usage: JsonElement? = null, + val info: JsonElement? = null, +) + +@Serializable +data class CompressionSummary( + val noop: Boolean = false, + val aborted: Boolean = false, + val refused_would_grow: Boolean? = null, + val fallback_used: Boolean? = null, + val headline: String = "", + val token_line: String = "", + val note: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index bbffae54..8b391ba0 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -63,6 +63,7 @@ data class SessionListResponse( val total: Int = 0, val limit: Int = 0, val offset: Int = 0, + val storage: Map = emptyMap(), ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt index 9ea2521a..a5a5e1a1 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.data.model import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.doubleOrNull @@ -59,6 +60,17 @@ data class SessionMessage( val display_metadata: JsonElement? = null, /** Token count recorded by the backend. */ val token_count: JsonElement? = null, + /** + * Public interim commentary projected by the backend for assistant rows + * (agent/history_commentary.py, issue #1284). Rendered as assistant text + * ahead of the reply. Absent on older gateways. + */ + val display_commentary: JsonElement? = null, + /** + * [reasoning] with the flattened commentary removed. When present (even + * empty) it wins over the raw reasoning so commentary is not shown twice. + */ + val display_reasoning: JsonElement? = null, ) { val timestampText: String? get() = (timestamp as? JsonPrimitive)?.content @@ -107,6 +119,41 @@ data class SessionMessage( else -> r.toString() } + /** True when the backend projected reasoning; it is then authoritative, even if empty. */ + val hasDisplayReasoning: Boolean + get() = (display_reasoning as? JsonPrimitive)?.isString == true + + /** [display_reasoning] when projected, else raw reasoning (older gateways). */ + val displayReasoningText: String + get() = (display_reasoning as? JsonPrimitive)?.takeIf { it.isString }?.content ?: reasoningText + + /** Nonblank public commentary items, in order. */ + val displayCommentary: List + get() = + (display_commentary as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content?.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + /** + * Visible assistant text: interim commentary, then the reply. Commentary that + * merely repeats the reply (some providers persist it as content) renders once. + */ + val visibleText: String + get() { + val reply = displayContentText ?: contentText + val commentary = displayCommentary + if (commentary.isEmpty()) return reply + val joined = commentary.joinToString("\n\n") + if (reply.isBlank()) return joined + + fun normalized(value: String) = value.replace(WHITESPACE, " ").trim() + if (normalized(joined) == normalized(reply)) return reply + return "$joined\n\n$reply" + } + val toolCallId: String get() = tool_call_id.orEmpty() } + +private val WHITESPACE = Regex("\\s+") diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Tts.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Tts.kt new file mode 100644 index 00000000..85ff2e49 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Tts.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +/** + * Request body for `POST /api/audio/speak` — the dashboard's server-side + * text-to-speech relay. + */ +@Serializable +data class TtsSpeakRequest( + val text: String, +) + +/** + * Response of `POST /api/audio/speak`. + * + * Returns base64 encoded [data_url] with [mime_type] and the [provider] used, + * or [ok] = false on failure. + */ +@Serializable +data class TtsSpeakResponse( + val ok: Boolean = false, + val data_url: String? = null, + val mime_type: String? = null, + val provider: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt index 369d9a43..584e816d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt @@ -95,9 +95,12 @@ object ApiClient { * the shared 30s client, and a re-submitted POST would repeat expensive * provider work after the request may already have reached the server * (the repository also passes `retries = 0`). The returned service reuses - * the shared connection pool and the auth/profile interceptor stack, and - * applies [readTimeoutMs] to reads and writes with transport retries - * disabled (review, PR #1250). + * the shared connection pool and the auth/profile interceptor stack. It + * applies [readTimeoutMs] to reads and writes and keeps transport-level + * retries on: they re-dial and can resend depending on when a failure + * struck, so they do not guarantee the absence of duplicate provider + * work — the risk is small, not zero. Application-level retry stays off + * (review, PR #1250 / #1280). */ fun transcriptionService(readTimeoutMs: Long): HermesApiService { val client = @@ -109,7 +112,16 @@ object ApiClient { .authenticator(TokenRefreshAuthenticator) .readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS) .writeTimeout(readTimeoutMs, TimeUnit.MILLISECONDS) - .retryOnConnectionFailure(false) + // Transport-level stale-socket rescue stays ON: the shared pool + // keeps connections far longer than the dashboard's keep-alive, + // so the first STT call after an idle spell can ride a socket + // the server just closed ("unexpected end of stream"). OkHttp + // re-dials and can resend depending on when the failure struck, + // and it may retry across routes — duplicate provider work is + // possible, though rare. Timeouts and 5xx are never replayed + // (safeApiCall still runs with retries = 0). Risk small, not + // zero (review, PR #1280). + .retryOnConnectionFailure(true) .build() return Retrofit .Builder() diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 55e6cddf..a2a486c6 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -134,6 +134,8 @@ import com.m57.hermescontrol.data.model.ToolsetPostSetupResponse import com.m57.hermescontrol.data.model.ToolsetProviderSelectRequest import com.m57.hermescontrol.data.model.ToolsetProviderSelectResponse import com.m57.hermescontrol.data.model.ToolsetToggleRequest +import com.m57.hermescontrol.data.model.TtsSpeakRequest +import com.m57.hermescontrol.data.model.TtsSpeakResponse import com.m57.hermescontrol.data.model.UpdateCheckResponse import com.m57.hermescontrol.data.model.UpdateCronJobRequest import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest @@ -670,7 +672,8 @@ interface HermesApiService : KanbanApiService { @POST("api/dashboard/agent-plugins/{name}/update") suspend fun updatePlugin( @Path("name", encoded = true) name: String, - ): Response + @Body body: com.m57.hermescontrol.data.model.PluginUpdateRequest, + ): Response @POST("api/dashboard/agent-plugins/{name}/enable") suspend fun enablePlugin( @@ -1042,4 +1045,10 @@ interface HermesApiService : KanbanApiService { suspend fun transcribeAudio( @Body body: AudioTranscriptionRequest, ): Response + + @POST("api/audio/speak") + suspend fun speakText( + @Body request: TtsSpeakRequest, + @Query("profile") profile: String? = null, + ): Response } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt index bda2d5aa..99a004eb 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt @@ -33,6 +33,7 @@ object OkHttpProvider { OkHttpClient .Builder() .cookieJar(resolveCookieJar()) + .addNetworkInterceptor(ServerHeadersInterceptor()) .connectionPool(connectionPool) .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) @@ -48,6 +49,9 @@ object OkHttpProvider { .cookieJar(resolveCookieJar()) .readTimeout(0, TimeUnit.MILLISECONDS) .pingInterval(30, TimeUnit.SECONDS) + .addInterceptor(ServerHeadersInterceptor()) + .followRedirects(false) + .followSslRedirects(false) .build() } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ServerHeaders.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ServerHeaders.kt new file mode 100644 index 00000000..b0ead8c2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ServerHeaders.kt @@ -0,0 +1,136 @@ +package com.m57.hermescontrol.data.remote + +import android.content.SharedPreferences +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException +import java.util.Locale + +/** Validated header values. Never include credentials in diagnostics or toString(). */ +class CustomHeaders private constructor( + val entries: Map, +) { + override fun toString(): String = "CustomHeaders(${entries.size} entries)" + + companion object { + val EMPTY = CustomHeaders(emptyMap()) + private val namePattern = Regex("[!#$%&'*+.^_`|~0-9A-Za-z-]+") + private val reserved = + setOf( + "authorization", + "proxy-authorization", + "cookie", + "host", + "connection", + "content-length", + "transfer-encoding", + "upgrade", + "te", + "trailer", + ) + + fun parse(rows: List>): CustomHeaders { + val result = linkedMapOf() + val names = mutableSetOf() + rows.filterNot { it.first.isBlank() && it.second.isEmpty() }.forEach { (rawName, value) -> + val name = rawName.trim() + val normalized = name.lowercase(Locale.ROOT) + // Do not expose OkHttp's validation exceptions: they can contain secret values. + require(namePattern.matches(name)) { "Invalid header name" } + require(normalized !in reserved && !normalized.startsWith("sec-websocket-")) { + "Header is managed by the app" + } + require(names.add(normalized)) { "Duplicate header name" } + require(value.all { it == '\t' || it in ' '..'~' }) { "Invalid header value" } + result[name] = value + } + return CustomHeaders(result.toMap()) + } + } +} + +/** Headers belong to a canonical server URL, including its reverse-proxy path. */ +class ScopedServerHeaders( + val baseUrl: HttpUrl, + val headers: CustomHeaders, +) { + fun contains(url: HttpUrl): Boolean = + url.scheme == baseUrl.scheme && url.host == baseUrl.host && url.port == baseUrl.port && + ( + url.encodedPath.startsWith(baseUrl.encodedPath) || + url.encodedPath == baseUrl.encodedPath.removeSuffix("/") + ) +} + +/** Uses AuthManager's encrypted preferences, never the plaintext server_store.json. */ +object ServerHeaders { + private const val KEY = "server_custom_headers" + + @Volatile + private var configured: List = emptyList() + private var preferences: SharedPreferences? = null + + @Synchronized + fun initialize(encryptedPreferences: SharedPreferences) { + val stored = encryptedPreferences.getString(KEY, null) + configured = + if (stored == null) { + emptyList() + } else { + val decoded = + try { + Json.decodeFromString>>(stored) + } catch (_: SerializationException) { + // JSON parser exceptions include the input, which contains credentials. + throw IOException("Could not read custom headers") + } + decoded.map { (url, values) -> + ScopedServerHeaders(ServerEndpoint.parseForBuild(url).baseUrl, CustomHeaders.parse(values.toList())) + } + } + preferences = encryptedPreferences + } + + fun get(baseUrl: HttpUrl): CustomHeaders = + configured.firstOrNull { it.baseUrl == baseUrl }?.headers ?: CustomHeaders.EMPTY + + fun forRequest(url: HttpUrl): ScopedServerHeaders? = + configured.filter { it.contains(url) }.maxByOrNull { it.baseUrl.encodedPath.length } + + /** Call on IO. Publish only after the encrypted write succeeds. */ + @Synchronized + fun save( + baseUrl: HttpUrl, + headers: CustomHeaders, + ) { + val prefs = checkNotNull(preferences) { "Server headers are not initialized" } + val updated = configured.filterNot { it.baseUrl == baseUrl } + ScopedServerHeaders(baseUrl, headers) + val serialized = Json.encodeToString(updated.associate { it.baseUrl.toString() to it.headers.entries }) + if (!prefs.edit().putString(KEY, serialized).commit()) throw IOException("Could not save custom headers") + configured = updated + } +} + +/** + * HTTP uses this as a network interceptor so redirects are checked before every exchange. + * OkHttp skips network interceptors for WebSockets, which use it as an application interceptor + * with redirects disabled. Credentials never follow a request outside its original server scope. + */ +class ServerHeadersInterceptor( + private val resolve: (HttpUrl) -> ScopedServerHeaders? = ServerHeaders::forRequest, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val scope = resolve(chain.call().request().url) ?: return chain.proceed(request) + val builder = request.newBuilder() + val withinScope = scope.contains(request.url) && resolve(request.url)?.baseUrl == scope.baseUrl + scope.headers.entries.forEach { (name, value) -> + if (withinScope) builder.header(name, value) else builder.removeHeader(name) + } + return chain.proceed(builder.build()) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt index 3dabae8a..2c15a06d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt @@ -1,13 +1,17 @@ package com.m57.hermescontrol.data.repository import com.m57.hermescontrol.data.model.AudioTranscriptionRequest +import com.m57.hermescontrol.data.model.AudioTranscriptionResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall +import kotlinx.coroutines.delay import java.io.File import java.io.IOException +import java.net.ConnectException +import java.net.UnknownHostException import java.util.Base64 /** @@ -33,6 +37,10 @@ const val VOICE_NOTE_MIME_TYPE = "audio/mp4" * - The STT POST runs on a dedicated long-timeout client with `retries = 0`: * re-submitting after a timeout or 5xx would repeat expensive provider * work although the request may already have reached the server. + * - Connect-never-established failures (unreachable route, DNS blip — the + * usual suspects across Wi-Fi/cellular switches) get ONE delayed + * re-attempt: that request never reached the server, so no provider work + * is duplicated (device follow-up, #1247). * * Open for test substitution (mirrors [ChatPersistenceRepository]'s pattern). */ @@ -54,16 +62,23 @@ open class VoiceNoteRepository( ) } val dataUrl = "data:$VOICE_NOTE_MIME_TYPE;base64," + Base64.getEncoder().encodeToString(encoded) - val result = - safeApiCall(retries = 0) { - serviceProvider(transcriptionTimeoutMs(dataUrl)).transcribeAudio( - AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE), - ) + val service = serviceProvider(transcriptionTimeoutMs(dataUrl)) + val result = requestTranscription(service, dataUrl) + // One bounded re-attempt, and only for connection-never-established + // failures — the one class where the request provably never reached + // the server, so no provider work is duplicated (device follow-up, + // #1247). + val finalResult = + if (result is NetworkResult.Failure && isConnectNeverEstablished(result.error)) { + delay(CONNECT_RETRY_DELAY_MS) + requestTranscription(service, dataUrl) + } else { + result } - return when (result) { + return when (finalResult) { is NetworkResult.Success -> { NetworkResult.Success( - result.data + finalResult.data ?.transcript .orEmpty() .trim(), @@ -71,11 +86,26 @@ open class VoiceNoteRepository( } is NetworkResult.Failure -> { - result + finalResult } } } + private suspend fun requestTranscription( + service: HermesApiService, + dataUrl: String, + ): NetworkResult = + safeApiCall(retries = 0) { + service.transcribeAudio( + AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE), + ) + } + + private fun isConnectNeverEstablished(error: NetworkError): Boolean { + val cause = (error as? NetworkError.Connection)?.cause ?: return false + return cause is ConnectException || cause is UnknownHostException + } + companion object { /** Floor for STT requests — desktop parity (`AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS`). */ const val MIN_REQUEST_TIMEOUT_MS = 180_000L @@ -85,6 +115,9 @@ open class VoiceNoteRepository( private const val BASE64_CHARS_PER_TIMEOUT_MS = 10L + /** One re-attempt window for connection-never-established failures. */ + private const val CONNECT_RETRY_DELAY_MS = 750L + /** * Timeout for the blocking STT endpoint, mirroring the desktop's * `audioTranscribeRequestTimeoutMs`: ~0.1ms of budget per base64 char diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt new file mode 100644 index 00000000..9a5fee1d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt @@ -0,0 +1,341 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.AccountConnectorResult +import com.m57.hermescontrol.data.model.ConnectorAccount +import com.m57.hermescontrol.data.model.ConnectorCatalogEntry +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorPolicy +import com.m57.hermescontrol.data.model.ConnectorTool +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +interface AccountConnectorRepository { + suspend fun catalog(): AccountConnectorResult> + + suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult + + suspend fun connect( + slugs: List, + reconnect: Boolean = false, + ): AccountConnectorResult + + suspend fun accounts(): AccountConnectorResult> + + suspend fun remove(connectionId: String): AccountConnectorResult + + suspend fun tools( + slug: String, + refresh: Boolean = false, + ): AccountConnectorResult> + + suspend fun policy(): AccountConnectorResult + + suspend fun setConnectorEnabled( + slug: String, + enabled: Boolean, + expectedRevision: String, + ): AccountConnectorResult + + suspend fun setDisabledTools( + slug: String, + disabled: List, + expectedRevision: String, + ): AccountConnectorResult + + suspend fun operationRequest( + method: String, + params: Map, + ): Any? + + suspend fun operationStatus( + opId: String, + ): AccountConnectorResult + + companion object : AccountConnectorRepository by HermesAccountConnectorRepository() +} + +class HermesAccountConnectorRepository( + private val rpc: suspend (String, Map) -> Any? = { method, params -> + val deferred = HermesWsClient.request(method, params) + try { + deferred.await() + } catch ( + e: CancellationException, + ) { + deferred.cancel(e) + throw e + } finally { + if (!deferred.isCompleted) deferred.cancel() + } + }, +) : AccountConnectorRepository { + private suspend fun request( + method: String, + params: Map = emptyMap(), + ) = rpc(method, params) + + private suspend fun guarded(block: suspend () -> T): AccountConnectorResult = + try { + AccountConnectorResult.Success(block()) + } catch (e: CancellationException) { + throw e + } catch (e: HermesWsClient.HermesRpcException) { + AccountConnectorResult.Failure(ConnectorParser.mapRpcError(e.code, e.message, e.data)) + } catch (_: IllegalArgumentException) { + AccountConnectorResult.Failure(ConnectorError.InvalidResponse()) + } catch (_: IllegalStateException) { + AccountConnectorResult.Failure(ConnectorError.InvalidResponse()) + } catch ( + _: Exception, + ) { + AccountConnectorResult.Failure(ConnectorError.NetworkError("Failed to communicate with gateway.")) + } + + private fun accountParams(params: Map = emptyMap()): Map = + buildMap { + putAll(params) + AuthManager.activeProfileId.value?.let { put("profile", it) } + } + + private fun obj(value: Any?): JsonObject = ConnectorParser.toJsonElement(value).jsonObject + + private fun JsonObject.str(k: String) = this[k]?.jsonPrimitive?.contentOrNull.orEmpty() + + private fun JsonObject.bool(k: String) = this[k]?.jsonPrimitive?.contentOrNull == "true" + + private fun JsonObject.array(k: String): JsonArray = this[k]?.jsonArray ?: JsonArray(emptyList()) + + private fun JsonObject.stringList(k: String) = array(k).mapNotNull { it.jsonPrimitive.contentOrNull } + + override suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult = + try { + ConnectorParser.parseListResult( + rpc( + WsMethods.CONNECTORS_LIST, + accountParams( + mapOf("owner" to mapOf("type" to "account")), + ), + ), + ) + } catch ( + e: CancellationException, + ) { + throw e + } catch ( + e: HermesWsClient.HermesRpcException, + ) { + com.m57.hermescontrol.data.model.ConnectorListResult.Error( + ConnectorParser.mapRpcError(e.code, e.message, e.data), + ) + } catch ( + _: Exception, + ) { + com.m57.hermescontrol.data.model.ConnectorListResult + .Error(ConnectorError.NetworkError()) + } + + override suspend fun connect( + slugs: List, + reconnect: Boolean, + ) = guarded { + require(slugs.isNotEmpty() && slugs.all(ConnectorRepository::isValidSlug)) + parseOperation( + request( + WsMethods.CONNECTORS_CONNECT, + accountParams( + mapOf( + "owner" to mapOf("type" to "account"), + "connectors" to slugs, + "reconnect" to reconnect, + ), + ), + ), + ) + } + + override suspend fun operationRequest( + method: String, + params: Map, + ): Any? = request(method, accountParams(params + ("owner" to mapOf("type" to "account")))) + + override suspend fun operationStatus(opId: String) = + guarded { + parseOperation(operationRequest(WsMethods.CONNECTORS_OPERATION_STATUS, mapOf("op_id" to opId))) + } + + @Suppress("UNCHECKED_CAST") + private fun parseOperation(raw: Any?): com.m57.hermescontrol.data.model.ConnectionOperationSnapshot = + checkNotNull( + ConnectionOperationParser.parse( + (ConnectorParser.toJsonElement(raw).toAny() as Map) + + ("owner" to mapOf("type" to "account")), + ), + ) + + override suspend fun catalog() = + guarded { + obj(request(WsMethods.CONNECTORS_CATALOG, accountParams()))["connectors"]!!.jsonArray.map { + val row = it.jsonObject + ConnectorCatalogEntry(row.str("slug"), row.str("name"), row.str("description"), row.str("category")) + } + } + + override suspend fun accounts() = + guarded { + obj(request(WsMethods.CONNECTORS_ACCOUNTS, accountParams()))["accounts"]!!.jsonArray.map { e -> + val r = e.jsonObject + ConnectorAccount( + r.str("connection_id"), + r.str("connector"), + r.str("status"), + r["status_reason"]?.jsonPrimitive?.contentOrNull, + r.str("label"), + r["alias"]?.jsonPrimitive?.contentOrNull, + r.bool("active"), + r.str("created_at"), + r.str("updated_at"), + ) + } + } + + override suspend fun remove(connectionId: String) = + guarded { + require(connectionId.isNotBlank()) + val response = + obj( + request( + WsMethods.CONNECTORS_ACCOUNTS_REMOVE, + accountParams( + mapOf("connection_id" to connectionId), + ), + ), + ) + check(response.str("status") == "removed" && response.str("connection_id") == connectionId) + Unit + } + + override suspend fun tools( + slug: String, + refresh: Boolean, + ) = guarded { + require(ConnectorRepository.isValidSlug(slug)) + val params = + buildMap { + put("slug", slug) + if (refresh) put("refresh", true) + } + obj(request(WsMethods.CONNECTORS_TOOLS, accountParams(params)))["tools"]!!.jsonArray.map { e -> + val r = e.jsonObject + ConnectorTool( + r.str("slug"), + r.str("name"), + r.str("description"), + r.str("facet"), + r.bool("deprecated"), + r.stringList("hints"), + ) + } + } + + override suspend fun policy() = + guarded { + val root = obj(request(WsMethods.CONNECTORS_POLICY_GET, accountParams())) + val layers = root.array("layers").map { it.jsonObject } + val member = layers.firstOrNull { it.str("kind") == "member" } + parsePolicy(root["effective"]!!.jsonObject).copy( + member = member?.let { parsePolicy(it["body"]!!.jsonObject, it.str("revision")) }, + inherited = + layers.filter { it.str("kind") != "member" }.map { + parsePolicy(it["body"]!!.jsonObject, it.str("revision")) + }, + ) + } + + override suspend fun setConnectorEnabled( + slug: String, + enabled: Boolean, + expectedRevision: String, + ) = setPolicy( + expectedRevision, + mapOf( + "type" to "connector", + "connector" to slug, + "enabled" to enabled, + ), + ) + + override suspend fun setDisabledTools( + slug: String, + disabled: List, + expectedRevision: String, + ) = setPolicy( + expectedRevision, + mapOf( + "type" to "tools", + "connector" to slug, + "disabled_tools" to disabled, + ), + ) + + private suspend fun setPolicy( + expectedRevision: String, + change: Map, + ) = guarded { + val scope = AuthManager.currentDataScope() + require(ConnectorRepository.isValidSlug(change["connector"] as? String)) + require(expectedRevision.isNotBlank()) + check(scope == AuthManager.currentDataScope()) { "Account scope changed" } + val result = + obj( + request( + WsMethods.CONNECTORS_POLICY_SET, + accountParams( + mapOf( + "change" to change, + "expected_revision" to expectedRevision, + ), + ), + ), + ) + check(scope == AuthManager.currentDataScope()) { "Account scope changed" } + when (val refreshed = policy()) { + is AccountConnectorResult.Success -> refreshed.value + is AccountConnectorResult.Failure -> parsePolicy(result["effective"]!!.jsonObject) + } + } + + private fun parsePolicy( + r: JsonObject, + revision: String = r.str("revision"), + ) = ConnectorPolicy( + revision, + r.str("mode"), + r.stringList("connectors"), + r.stringList("disabled_connectors"), + (r["tools"] as? JsonObject)?.mapValues { (_, v) -> + (v as? JsonArray)?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + } + ?: emptyMap(), + enabledTags = + (r["tags"] as? JsonObject) + ?.let { + (it["enable"] as? JsonArray)?.mapNotNull { tag -> + tag.jsonPrimitive.contentOrNull + } + }.orEmpty(), + disabledTags = + (r["tags"] as? JsonObject) + ?.let { + (it["disable"] as? JsonArray)?.mapNotNull { tag -> + tag.jsonPrimitive.contentOrNull + } + }.orEmpty(), + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt index 0e0fb7c4..411cce9b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt @@ -73,7 +73,6 @@ object CommandBlocklist { "/image", "/quit", // TUI-only extras (meaningless outside the TUI) - "/compact", "/logs", "/mouse", ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt index b9748882..04e490b5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt @@ -1,5 +1,8 @@ package com.m57.hermescontrol.data.ws +import com.m57.hermescontrol.data.model.CatalogScanStatus +import com.m57.hermescontrol.data.model.ConnectionCatalogInfo +import com.m57.hermescontrol.data.model.ConnectionCatalogScan import com.m57.hermescontrol.data.model.ConnectionEnvField import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot import com.m57.hermescontrol.data.model.ConnectionOperationTarget @@ -24,7 +27,7 @@ object ConnectionOperationParser { return null } return ConnectionOperationSnapshot( - sessionId = (sessionId ?: payload["session_id"] as? String)?.takeIf { it.isNotBlank() }, + sessionId = resolveSessionId(payload, sessionId), opId = opId, seq = seq, deadlineAt = deadlineAt, @@ -36,21 +39,38 @@ object ConnectionOperationParser { settled = payload["settled"] as? Boolean ?: false, settledBy = payload["settled_by"] as? String, targets = targets, + accountOwned = (payload["owner"] as? Map<*, *>)?.get("type") == "account", ) } + /** + * Session that owns the operation. Since hermes-agent v0.21.5 updates carry `owner` (#1281): + * session-owned ones name their session; account-owned ones never bind to a chat. + */ + private fun resolveSessionId( + payload: Map, + envelopeSessionId: String?, + ): String? { + val owner = payload["owner"] as? Map<*, *> + if (owner?.get("type") == "account") return null + val ownerSessionId = (owner?.get("session_id") as? String).takeIf { owner?.get("type") == "session" } + return listOf(envelopeSessionId, ownerSessionId, payload["session_id"] as? String) + .firstOrNull { !it.isNullOrBlank() } + } + private fun parseTarget(raw: Map<*, *>?): ConnectionOperationTarget? { val map = raw ?: return null val name = (map["name"] as? String)?.trim() ?: return null if (name.isBlank()) return null + val kind = + enumValue( + map["kind"] as? String, + ConnectionTargetKind.values(), + ConnectionTargetKind.UNKNOWN, + ) return ConnectionOperationTarget( name = name, - kind = - enumValue( - map["kind"] as? String, - ConnectionTargetKind.values(), - ConnectionTargetKind.UNKNOWN, - ), + kind = kind, action = enumValue( map["action"] as? String, @@ -75,9 +95,50 @@ object ConnectionOperationParser { ?: emptyList(), tools = (map["tools"] as? List<*>)?.filterIsInstance() ?: emptyList(), hint = map["hint"] as? String, + catalog = if (kind.isCatalog) parseCatalog(map, name) else null, ) } + private fun parseCatalog( + map: Map<*, *>, + name: String, + ): ConnectionCatalogInfo = + ConnectionCatalogInfo( + display = map.text("display") ?: name, + description = map.text("description"), + tier = map.text("tier"), + platforms = map.strings("platforms"), + repo = map.text("repo"), + sha = map.text("sha"), + subdir = map.text("subdir"), + scan = + (map["scan"] as? Map<*, *>)?.let { scan -> + ConnectionCatalogScan( + status = + enumValue( + scan["status"] as? String, + CatalogScanStatus.values(), + CatalogScanStatus.UNKNOWN, + ), + summary = scan.text("summary"), + ) + }, + requirements = map.strings("requirements"), + targetProfile = map.text("target_profile") ?: DEFAULT_PROFILE, + skill = map.text("skill"), + ) + + private fun Map<*, *>.text(key: String): String? = (this[key] as? String)?.trim()?.takeIf { it.isNotEmpty() } + + private fun Map<*, *>.strings(key: String): List = + (this[key] as? List<*>) + ?.filterIsInstance() + ?.map(String::trim) + ?.filter(String::isNotEmpty) + ?: emptyList() + + private const val DEFAULT_PROFILE = "default" + private fun parseEnv(raw: Map<*, *>?): ConnectionEnvField? { val map = raw ?: return null val name = (map["name"] as? String)?.trim() ?: return null diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt index 1c39ce40..a6535881 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt @@ -32,6 +32,9 @@ interface ConnectorRepository { if (slug.isNullOrBlank()) return false return SLUG_REGEX.matches(slug) } + + /** `owner` discriminated union required by connector RPCs since hermes-agent v0.21.5 (#1281). */ + fun sessionOwner(sessionId: String): Map = mapOf("type" to "session", "session_id" to sessionId) } } @@ -61,7 +64,7 @@ class HermesConnectorRepository( } return try { - val params = mapOf("session_id" to sessionId) + val params = mapOf("owner" to ConnectorRepository.sessionOwner(sessionId)) val result = rpcRequest(WsMethods.CONNECTORS_LIST, params) ConnectorParser.parseListResult(result) } catch (e: CancellationException) { @@ -105,7 +108,7 @@ class HermesConnectorRepository( return try { val params = mapOf( - "session_id" to sessionId, + "owner" to ConnectorRepository.sessionOwner(sessionId), "connectors" to connectors, "reconnect" to reconnect, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageModels.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageModels.kt new file mode 100644 index 00000000..a41b4c60 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageModels.kt @@ -0,0 +1,102 @@ +package com.m57.hermescontrol.data.ws + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +enum class PluginSettingFieldType { + @SerialName("string") + STRING, + + @SerialName("number") + NUMBER, + + @SerialName("boolean") + BOOLEAN, + + @SerialName("enum") + ENUM, + + @SerialName("secret") + SECRET, + + @SerialName("json") + JSON, +} + +@Serializable +data class PluginSettingField( + val key: String, + val type: PluginSettingFieldType, + val label: String, + val description: String = "", + val required: Boolean = false, + val value: JsonElement? = null, + val default: JsonElement? = null, + val choices: List? = null, + val env: String? = null, + @SerialName("has_value") val hasValue: Boolean? = null, +) + +@Serializable +enum class PluginServerState { + @SerialName("connected") + CONNECTED, + + @SerialName("app_not_running") + APP_NOT_RUNNING, + + @SerialName("endpoint_unavailable") + ENDPOINT_UNAVAILABLE, + + @SerialName("no_interactive_session") + NO_INTERACTIVE_SESSION, + + @SerialName("version_too_old") + VERSION_TOO_OLD, + + @SerialName("missing_app") + MISSING_APP, + + @SerialName("unknown") + UNKNOWN, +} + +@Serializable +data class PluginServerRow( + val name: String, + val state: PluginServerState, + val sentence: String = "", +) + +@Serializable +data class AgentPluginRow( + val name: String, + val key: String? = null, + val version: String = "", + val description: String = "", + val source: String = "", + val status: String = "", + val portable: Boolean = false, + @SerialName("install_dir") val installDir: String = "", + @SerialName("has_desktop_half") val hasDesktopHalf: Boolean = false, + val servers: List = emptyList(), + @SerialName("settings_schema") val settingsSchema: List? = null, + @SerialName("catalog_name") val catalogName: String? = null, + @SerialName("catalog_tier") val catalogTier: String? = null, + @SerialName("installed_sha") val installedSha: String? = null, + @SerialName("catalog_sha") val catalogSha: String? = null, + @SerialName("catalog_version") val catalogVersion: String? = null, + @SerialName("update_available") val updateAvailable: Boolean? = null, + @SerialName("pinned_sha") val pinnedSha: String? = null, +) + +@Serializable +data class PluginsManageSettingsResult( + val ok: Boolean = false, + val name: String? = null, + val written: List? = null, + val plugin: AgentPluginRow? = null, + val error: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt new file mode 100644 index 00000000..460e5bcf --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt @@ -0,0 +1,52 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement + +/** + * Manages plugin settings and inspection through the gateway's canonical `plugins.manage` RPC. + */ +class PluginManageRepository( + private val rpcRequest: suspend (String, Map) -> Any? = { method, params -> + val deferred = HermesWsClient.request(method, params.filterValues { it != null }.mapValues { it.value ?: "" }) + try { + deferred.await() + } catch (e: CancellationException) { + deferred.cancel(e) + throw e + } + }, +) { + suspend fun listPlugins(profile: String? = null): List { + val params = mutableMapOf("action" to "list") + if (!profile.isNullOrBlank()) { + params["profile"] = profile + } + val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params) + val element = result.toJsonElement() + val pluginsElement = (element as? JsonObject)?.get("plugins") ?: return emptyList() + return OkHttpProvider.json.decodeFromJsonElement>(pluginsElement) + } + + suspend fun saveSettings( + key: String, + values: Map, + profile: String? = null, + ): PluginsManageSettingsResult { + val params = + mutableMapOf( + "action" to "settings", + "key" to key, + "values" to JsonObject(values), + ) + if (!profile.isNullOrBlank()) { + params["profile"] = profile + } + val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params) + return OkHttpProvider.json.decodeFromJsonElement(result.toJsonElement()) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index cdbf2e51..f2e07a5a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -15,6 +15,8 @@ object WsMethods { const val SESSION_DELETE = "session.delete" const val SESSION_TITLE = "session.title" const val SESSION_BRANCH = "session.branch" + const val SESSION_BRANCH_WHOLE = "session.branch_whole" + const val SESSION_COMPRESS = "session.compress" /** Replay recorded events newer than client's last-seen seq for a session. */ const val SESSION_EVENTS_SINCE = "session.events.since" @@ -104,12 +106,19 @@ object WsMethods { const val CONNECTION_RESPOND = "connection.respond" const val CONNECTORS_OPERATION_WAKE = "connectors.operation.wake" + const val CONNECTORS_OPERATION_STATUS = "connectors.operation.status" /** List available third-party connectors and authorization state for a session. */ const val CONNECTORS_LIST = "connectors.list" /** Initiate or restart authorization flow for one or more connectors. */ const val CONNECTORS_CONNECT = "connectors.connect" + const val CONNECTORS_CATALOG = "connectors.catalog" + const val CONNECTORS_ACCOUNTS = "connectors.accounts" + const val CONNECTORS_ACCOUNTS_REMOVE = "connectors.accounts.remove" + const val CONNECTORS_TOOLS = "connectors.tools" + const val CONNECTORS_POLICY_GET = "connectors.policy.get" + const val CONNECTORS_POLICY_SET = "connectors.policy.set" // ── Plugins ─────────────────────────────────────────────────────────── const val PLUGINS_MANAGE = "plugins.manage" @@ -165,6 +174,12 @@ object WsMethods { SESSION_CREATE, // session.create SESSION_LIST, // session.list MODEL_OPTIONS, // model.options: @_profile_scoped + CONNECTORS_CATALOG, + CONNECTORS_ACCOUNTS, + CONNECTORS_ACCOUNTS_REMOVE, + CONNECTORS_TOOLS, + CONNECTORS_POLICY_GET, + CONNECTORS_POLICY_SET, SESSION_RESUME, // session.resume SESSION_DELETE, // session.delete SESSION_STATUS, // session.status diff --git a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt index d4c119f4..18c06a53 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.common.CustomHeadersButton @Composable fun AuthLoginScreen( @@ -162,7 +163,13 @@ fun AuthLoginScreen( singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - enabled = state.authMode == null, + enabled = state.authMode == null && !state.probing && !state.isLoading, + ) + + CustomHeadersButton( + baseUrl = state.baseUrl, + enabled = !state.probing && !state.isLoading, + onSaved = viewModel::probe, ) // Cleartext transport warning diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt index 566d7f0e..92bce3f6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt @@ -11,6 +11,21 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +internal fun buildLegacyClarifyBatchResponses( + questions: List, + answers: Map, + lockedAnswers: Map, +): List> = + questions + .asSequence() + // A replayed pending_clarify can contain answers already locked by the + // server. ClarifyBubble has no editable input for those rows, so its + // answer map carries an empty placeholder. Never send that placeholder + // back through legacy clarify.respond and overwrite accepted state. + .filterNot { it.qid in lockedAnswers } + .map { it.qid to answers[it.qid]?.trim().orEmpty() } + .toList() + class ChatClarifyDelegate( private val uiState: MutableStateFlow, private val scope: CoroutineScope, @@ -114,14 +129,17 @@ class ChatClarifyDelegate( } if (isBatch) { - for (q in questions) { - val ans = answers[q.qid]?.trim().orEmpty() + for ((questionId, ans) in buildLegacyClarifyBatchResponses( + questions = questions, + answers = answers, + lockedAnswers = lockedAnswers, + )) { val params = mutableMapOf( "session_id" to sessionId, "response" to ans, "answer" to ans, - "question_id" to q.qid, + "question_id" to questionId, ) if (clarifyId != null) { params["clarify_id"] = clarifyId diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt index 7d03246f..14ab2ba6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt @@ -1,6 +1,9 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.ws.ConnectorParser +import com.m57.hermescontrol.data.ws.ConnectorRepository import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsMethods import kotlinx.coroutines.CancellationException @@ -57,7 +60,10 @@ internal class ConnectionResumeCheckpoint( /** Session-scoped authoritative reducer for backend-owned connector operations. */ class ChatConnectionOperationDelegate( private val requester: ConnectionOperationRequester, + private val accountOwned: Boolean, ) { + constructor(requester: ConnectionOperationRequester) : this(requester, false) + private val _state = MutableStateFlow(ConnectionOperationUiState()) val state: StateFlow = _state.asStateFlow() private var sessionId: String? = null @@ -167,7 +173,7 @@ class ChatConnectionOperationDelegate( method = WsMethods.CONNECTION_RESPOND, params = mapOf( - "session_id" to requireSessionId(), + "owner" to ownerParams(), "op_id" to snapshot.opId, "result" to mapOf("targets" to listOf(answer)), ), @@ -180,7 +186,7 @@ class ChatConnectionOperationDelegate( method = WsMethods.CONNECTION_RESPOND, params = mapOf( - "session_id" to requireSessionId(), + "owner" to ownerParams(), "op_id" to snapshot.opId, "result" to mapOf("settled_by" to "continue"), ), @@ -193,7 +199,7 @@ class ChatConnectionOperationDelegate( val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return dispatch( method = WsMethods.CONNECTORS_OPERATION_WAKE, - params = mapOf("session_id" to requireSessionId(), "op_id" to snapshot.opId), + params = mapOf("owner" to ownerParams(), "op_id" to snapshot.opId), clearOnSuccess = true, unknownOperationSettles = true, ) @@ -225,7 +231,7 @@ class ChatConnectionOperationDelegate( _state.value = _state.value.copy( pendingAction = null, - error = ConnectionOperationError("request_failed"), + error = operationError(error), ) } } @@ -234,7 +240,7 @@ class ChatConnectionOperationDelegate( @Synchronized private fun begin(action: ConnectionPendingAction): ConnectionOperationSnapshot? { val current = _state.value.operation ?: return null - if (sessionId.isNullOrBlank() || + if ((!accountOwned && sessionId.isNullOrBlank()) || _state.value.pendingAction != null || current.opId != action.opId || current.seq != action.observedSeq @@ -245,10 +251,26 @@ class ChatConnectionOperationDelegate( return current } - private fun matchesSession(snapshot: ConnectionOperationSnapshot): Boolean = - !sessionId.isNullOrBlank() && snapshot.sessionId == sessionId + /** Typed, sanitized error: ownership/runtime failures cannot succeed on retry (#1281). */ + private fun operationError(error: Exception): ConnectionOperationError { + val rpc = error as? HermesWsClient.HermesRpcException ?: return ConnectionOperationError(REQUEST_FAILED) + return when (ConnectorParser.mapRpcError(code = rpc.code, data = rpc.data)) { + is ConnectorError.NotOwner -> ConnectionOperationError(NOT_OWNER, retryable = false) + is ConnectorError.UnsupportedRuntime -> ConnectionOperationError(UNSUPPORTED_RUNTIME, retryable = false) + else -> ConnectionOperationError(REQUEST_FAILED) + } + } - private fun requireSessionId(): String = checkNotNull(sessionId?.takeIf { it.isNotBlank() }) + private fun matchesSession(snapshot: ConnectionOperationSnapshot): Boolean = + if (accountOwned) { + snapshot.accountOwned + } else { + !snapshot.accountOwned && !sessionId.isNullOrBlank() && + snapshot.sessionId == sessionId + } + + private fun ownerParams(): Map = + if (accountOwned) mapOf("type" to "account") else ConnectorRepository.sessionOwner(checkNotNull(sessionId)) private fun currentOp(): String = _state.value.operation @@ -263,7 +285,10 @@ class ChatConnectionOperationDelegate( _state.value = ConnectionOperationUiState() } - private companion object { - const val MAX_SETTLED_IDS = 32 + companion object { + private const val MAX_SETTLED_IDS = 32 + const val REQUEST_FAILED = "request_failed" + const val NOT_OWNER = "not_owner" + const val UNSUPPORTED_RUNTIME = "unsupported_runtime" } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 4a33fb46..badd3eb2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -107,6 +107,12 @@ data class ChatMessage( * Transient; UNKNOWN provenance and the persisted row remain unchanged. */ val isRestoredUnconfirmed: Boolean = false, + /** + * Gateway SQLite row id (#1285): from REST `id`, the `prompt.submit` `user_row_id` + * receipt, or `persisted_turn.final_assistant_row_id`. Null means unproven, never rejected. + * Scoped to the owning profile store; transient. + */ + val serverRowId: Long? = null, ) /** Cached REST rows already carry their canonical identity in the persisted primary key. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistedReceipts.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistedReceipts.kt new file mode 100644 index 00000000..08d030fc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistedReceipts.kt @@ -0,0 +1,30 @@ +package com.m57.hermescontrol.ui.chat + +/** + * `message.complete` `persisted_turn` (hermes-agent `PersistedTurn`, #1285). Only the anchors Mobile + * can bind without inference are kept; `row_ids` for intermediate rows would need positional + * guessing. A missing id is unproven, never a rejection, and only [complete] permits retiring the + * whole local turn. Row ids are scoped to the owning profile store. + */ +internal data class PersistedTurn( + val complete: Boolean, + val userRowId: Long? = null, + val finalAssistantRowId: Long? = null, +) + +/** Decoded JSON numbers may arrive as Int, Long, or Double; booleans and non-positive ids are not ids. */ +internal fun positiveRowId(value: Any?): Long? = + when (value) { + is Int, is Long -> (value as Number).toLong() + is Double -> value.takeIf { it % 1.0 == 0.0 }?.toLong() + else -> null + }?.takeIf { it > 0L } + +internal fun parsePersistedTurn(payload: Map<*, *>?): PersistedTurn? { + val raw = payload?.get("persisted_turn") as? Map<*, *> ?: return null + return PersistedTurn( + complete = raw["complete"] == true, + userRowId = positiveRowId(raw["user_row_id"]), + finalAssistantRowId = positiveRowId(raw["final_assistant_row_id"]), + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt deleted file mode 100644 index c95b505d..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -/** Presentation only: retain queued messages internally for persistence and receipt matching. */ -internal fun messagesWithoutUnsentQueue( - messages: List, - pendingSends: List, -): List { - val waitingIds = - pendingSends - .filter { it.state == PendingSendState.QUEUED || it.state == PendingSendState.PARKED } - .map { it.id } - .toSet() - if (waitingIds.isEmpty()) return messages - return messages.filterNot { - it.role == MessageRole.USER && it.id in waitingIds && it.canonicalRestId == null - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index ee9d91da..1568e8dd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -84,10 +84,11 @@ import com.m57.hermescontrol.HistoryScreen import com.m57.hermescontrol.LogsScreen import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource -import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.reasoningSupport +import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.notification.NotificationHelper @@ -111,10 +112,13 @@ import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard import com.m57.hermescontrol.ui.chat.components.SearchBarRow import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet +import com.m57.hermescontrol.ui.chat.components.SpeechRequest +import com.m57.hermescontrol.ui.chat.components.SpeechText import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet import com.m57.hermescontrol.ui.chat.components.TaskProgressChip import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController +import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList @@ -173,11 +177,7 @@ fun ChatScreen( // Snapshot-backed search state — read directly so only the scopes that // read its fields recompose on search changes (bar, matched bubbles). val searchState = viewModel.searchState - val sourceMessages = timelineState.historyMessages ?: state.messages - val displayedMessages = - remember(sourceMessages, state.pendingSends) { - messagesWithoutUnsentQueue(sourceMessages, state.pendingSends) - } + val displayedMessages = timelineState.historyMessages ?: state.messages val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current var browserAuthInFlight by rememberSaveable { mutableStateOf(false) } @@ -249,6 +249,10 @@ fun ChatScreen( } val browserEvent = connectorsState.browserLaunchEvent + val speechController = rememberChatSpeech() + val speakingMessageId by speechController.speakingId.collectAsStateWithLifecycle() + val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle() + val activeSessionId by ActiveSessionHolder.activeSessionId.collectAsStateWithLifecycle() val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy) val scrollScope = rememberCoroutineScope() val scrollController = rememberChatScrollController(listState, scrollScope) @@ -798,7 +802,20 @@ fun ChatScreen( onSaveAttachment = onSaveAttachment, savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath, openingAttachmentPath = state.openingAttachmentPath, + isCompressing = state.isCompressing && !timelineState.isHistorical, + compressionStatus = state.compressionStatus.takeUnless { timelineState.isHistorical }, onImageClick = { viewingImage = it }, + speakingMessageId = speakingMessageId, + onToggleSpeak = { message -> + val scopeKey = "${dataScope?.inMemoryKey(localKey = activeSessionId ?: "none")}" + speechController.toggle( + SpeechRequest( + scopeKey = scopeKey, + messageId = message.id, + text = SpeechText.stripMarkdownForSpeech(message.content), + ), + ) + }, replyErrorContent = state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure -> { @@ -882,12 +899,6 @@ fun ChatScreen( }, ) - com.m57.hermescontrol.ui.chat.components.PendingSendPanel( - sends = state.pendingSends, - mainTurnBusy = state.isMainTurnBusy, - onSendNow = viewModel::sendQueuedNow, - ) - ChatInputBar( inputFieldValue = inputFieldValue, onInputChange = { inputFieldValue = it }, @@ -898,22 +909,16 @@ fun ChatScreen( scrollController.jumpToBottom(animated = true) } }, - onBusySend = { mode -> - if (viewModel.sendMessage(inputFieldValue.text, mode)) { - inputFieldValue = TextFieldValue("") - scrollController.jumpToBottom(animated = true) - } - }, onMicTap = mediaLaunchers.onMicTap, onMicHoldStart = mediaLaunchers.onMicHoldStart, onMicHoldEnd = mediaLaunchers.onMicHoldEnd, onMicHoldCancel = mediaLaunchers.onMicHoldCancel, + onMicLock = mediaLaunchers.onMicLock, isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote, isRecordingVoice = mediaLaunchers.isRecordingVoice, - voiceNoteAmplitude = mediaLaunchers.voiceNoteAmplitude, + isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked, onStopGeneration = { viewModel.interruptSession() }, isAgentTyping = state.isAgentTyping, - isMainTurnBusy = state.isMainTurnBusy, canInterrupt = state.canInterrupt, isConnected = state.isConnected, isSessionReady = state.isSessionReady && !timelineState.isHistorical, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt index a4a9263e..4b659f0e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt @@ -21,6 +21,8 @@ data class PendingSend( val attempts: Int = 0, /** Legacy source URIs are retained for an explicit user retry, never auto-dispatched. */ val requiresAttachmentRecovery: Boolean = false, + /** `prompt.submit` `user_row_id` receipt (#1285); null means unproven, not rejected. */ + val userRowId: Long? = null, ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt index 79a9972b..1e67bfc2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt @@ -6,6 +6,8 @@ import com.m57.hermescontrol.data.model.AttachmentSource import com.m57.hermescontrol.data.model.SessionMessage import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.notification.ReplyNotificationTracker +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import kotlinx.serialization.json.JsonObject /** * Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s. @@ -179,13 +181,30 @@ internal fun mapServerMessages( ?: existingById[restId]?.timestamp ?: System.currentTimeMillis() - val rawContent = msg.displayContentText ?: msg.contentText - val rowReasoning = - msg.reasoningText.ifBlank { - if (role == MessageRole.ASSISTANT) { - reasoningSources[index]?.reasoningText.orEmpty() + val rawContent = + if (role == MessageRole.TOOL && msg.display_metadata != null) { + // Enrich tool content with display_metadata so persisted edit previews + // (inline_diff) survive reload into the diff renderer + val parsedContent = ToolJson.parseMaybeObject(msg.content) + if (parsedContent != null && !parsedContent.containsKey("display_metadata")) { + JsonObject(parsedContent + ("display_metadata" to msg.display_metadata)).toString() } else { - existingById[restId]?.reasoningText.orEmpty() + msg.visibleText + } + } else { + msg.visibleText + } + // #1284: projected reasoning already excludes public commentary; it is authoritative. + val rowReasoning = + if (msg.hasDisplayReasoning) { + msg.displayReasoningText + } else { + msg.reasoningText.ifBlank { + if (role == MessageRole.ASSISTANT) { + reasoningSources[index]?.reasoningText.orEmpty() + } else { + existingById[restId]?.reasoningText.orEmpty() + } } } @@ -254,6 +273,7 @@ internal fun mapServerMessages( displayKind = msg.display_kind, tokenCount = tokenCount, completionId = completionId, + serverRowId = msg.id?.toLong()?.takeIf { it > 0L }, ), ) } @@ -269,12 +289,13 @@ internal fun mapServerMessages( // A canonical tool-result row settles a cached tool.start, not the reverse. local.copy( restId = message.canonicalRestId, + serverRowId = message.serverRowId, content = message.content, toolStatus = ToolStatus.COMPLETED, isHistoricalCache = false, ) } else { - local?.copy(restId = message.canonicalRestId) ?: message + local?.copy(restId = message.canonicalRestId, serverRowId = message.serverRowId) ?: message } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt index 474b164b..4d0214f1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt @@ -151,6 +151,8 @@ internal class TranscriptComparison( b: ChatMessage, ): Boolean { if (a.role != b.role) return false + // #1285: two proven row ids decide identity; a missing one is unproven, so fall through. + if (a.serverRowId != null && b.serverRowId != null) return a.serverRowId == b.serverRowId val aRestId = a.canonicalRestId val bRestId = b.canonicalRestId if (aRestId != null && bRestId != null) return aRestId == bRestId @@ -213,10 +215,20 @@ internal fun matchTranscriptMessages( val byId = existing.withIndex().associate { it.value.id to it.index } val byRestId = existing.withIndex().mapNotNull { (index, message) -> message.canonicalRestId?.let { it to index } }.toMap() + val byRowId = + existing + .withIndex() + .mapNotNull { (index, message) -> + message.serverRowId?.let { (message.role to it) to index } + }.toMap() val used = BooleanArray(existing.size) val matches = arrayOfNulls(incoming.size) incoming.forEachIndexed { index, message -> - (byId[message.id] ?: message.canonicalRestId?.let { byRestId[it] })?.let { match -> + ( + byId[message.id] + ?: message.canonicalRestId?.let { byRestId[it] } + ?: message.serverRowId?.let { byRowId[message.role to it] } + )?.let { match -> if (!used[match]) { used[match] = true matches[index] = existing[match] @@ -333,6 +345,7 @@ internal fun dedupeCachedMessages( aliases[message.id]?.let { message.copy( restId = it.canonicalRestId, + serverRowId = message.serverRowId ?: it.serverRowId, completionId = message.completionId ?: it.completionId, displayKind = message.displayKind ?: it.displayKind, isRestoredUnconfirmed = false, @@ -402,6 +415,7 @@ internal fun mergeCachedTranscriptPage( id = match.id, content = preservedContent ?: rich.content, restId = match.canonicalRestId ?: message.canonicalRestId, + serverRowId = match.serverRowId ?: message.serverRowId, completionId = match.completionId ?: message.completionId, displayKind = match.displayKind ?: message.displayKind, isRestoredUnconfirmed = @@ -439,6 +453,7 @@ internal fun mergeTranscriptWithLive( match?.role == MessageRole.USER -> { match.copy( restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id }, + serverRowId = message.serverRowId ?: match.serverRowId, displayKind = message.displayKind ?: match.displayKind, isHistoricalCache = false, isRestoredUnconfirmed = false, @@ -457,6 +472,7 @@ internal fun mergeTranscriptWithLive( } match.copy( restId = message.canonicalRestId ?: match.canonicalRestId, + serverRowId = message.serverRowId ?: match.serverRowId, content = mergedContent, timestamp = message.timestamp, isStreaming = message.isStreaming, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index a46daaab..465e4356 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.ModelCapabilities import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.model.SessionCompressResponse import com.m57.hermescontrol.data.model.SessionTimelineEntry import com.m57.hermescontrol.data.model.UsageSnapshotResponse import com.m57.hermescontrol.data.model.parseContextBreakdown @@ -234,6 +235,8 @@ data class ChatUiState( // until the first successful session.usage fetch) — drives the // "compressed ×N" badge on the context chip. val compressionCount: Int? = null, + val isCompressing: Boolean = false, + val compressionStatus: String? = null, /** Rolling output tokens/sec over the last ~10 calls. */ val latestTps: Double? = null, /** Latest cumulative backend usage snapshot for the current session. */ @@ -483,7 +486,13 @@ class ChatViewModel( val originalPending: PendingSend? = null, ) + private data class PendingBranchRequest( + val generation: Long, + val params: Map, + ) + private val sessionRequestById = ConcurrentHashMap() + private val branchWholeRequests = ConcurrentHashMap() private val outgoingRequestById = ConcurrentHashMap() private val hardInterruptRequestById = ConcurrentHashMap() private val queuedStagingIds = ConcurrentHashMap.newKeySet() @@ -1063,6 +1072,7 @@ class ChatViewModel( if (isCurrentSession(event.sessionId)) { mainTurnBusy = false lastMainCompletionAt = System.currentTimeMillis() + retireReceiptForPersistedTurn(parsePersistedTurn(event.rawPayload)) refreshSendReceipts() } // Buffers cleared before reduce; ViewModel resets them after @@ -1602,7 +1612,8 @@ class ChatViewModel( result: Any?, ) { val messageId = outgoingRequestById.remove(requestId) ?: return - val status = rpcResultMap(result)?.get("status") as? String + val resultMap = rpcResultMap(result) + val status = resultMap?.get("status") as? String val accepted = when (method) { WsMethods.SESSION_REDIRECT -> status == "redirected" || status == "queued" @@ -1611,6 +1622,24 @@ class ChatViewModel( } when { accepted -> { + // #1285: the submit ack names the row written for THIS input. Absent = unproven. + positiveRowId(resultMap?.get("user_row_id"))?.let { rowId -> + sendStore.update(messageId) { it.copy(userRowId = rowId) } + _uiState.update { state -> + state.copy( + messages = + state.messages.map { + if (it.id == messageId && + it.serverRowId == null + ) { + it.copy(serverRowId = rowId) + } else { + it + } + }, + ) + } + } markPendingSend(messageId, PendingSendState.ACCEPTED) if (sendStore.all().any { it.id == messageId && lastMainCompletionAt >= it.createdAt }) { refreshSendReceipts() @@ -1643,6 +1672,7 @@ class ChatViewModel( ) { val method = idToMethod.remove(id) ?: return val request = sessionRequestById.remove(id) + branchWholeRequests.remove(id) if (request != null && isStaleSessionRequest(request)) { outgoingRequestById.remove(id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) } hardInterruptRequestById.remove(id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) } @@ -1729,7 +1759,9 @@ class ChatViewModel( } } - WsMethods.SESSION_BRANCH -> { + WsMethods.SESSION_BRANCH, + WsMethods.SESSION_BRANCH_WHOLE, + -> { val resultMap = result as? Map ?: return // The result carries BOTH ids: `session_id` is the runtime // registry id, `stored_session_id` is the DB key. currentSessionId @@ -2066,6 +2098,7 @@ class ChatViewModel( ) { val method = idToMethod.remove(id) ?: return val request = sessionRequestById.remove(id) + val pendingBranch = branchWholeRequests.remove(id) if (request != null && isStaleSessionRequest(request)) { outgoingRequestById.remove(id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) } hardInterruptRequestById.remove(id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) } @@ -2145,6 +2178,26 @@ class ChatViewModel( } } + if (method == WsMethods.SESSION_BRANCH_WHOLE) { + val code = + (error as? JsonRpcError)?.code + ?: (error as? Map<*, *>)?.get("code") as? Int + if (code == -32601 && pendingBranch != null && pendingBranch.generation == sessionGeneration) { + val params = pendingBranch.params + val generation = pendingBranch.generation + viewModelScope.launch(ioDispatcher) { + wsClient.send( + WsMethods.SESSION_BRANCH, + params, + onSent = { branchId -> + trackSessionRequest(branchId, WsMethods.SESSION_BRANCH, generation) + }, + ) + } + return + } + } + // Surface error in UI (these are server-pushed RpcError for // fire-and-forget RPCs — awaited RPCs handle their own failure // via the HermesWsClient.request() deferred). @@ -2489,6 +2542,23 @@ class ChatViewModel( publishPendingSends() } + /** + * #1285: only a complete receipt proves the whole turn is durable, and only the receipt + * already bound to that exact `user_row_id` is retired. Partial or id-less receipts leave + * every local row to REST reconciliation; no row is ever inferred from text or position. + */ + private fun retireReceiptForPersistedTurn(turn: PersistedTurn?) { + val userRowId = turn?.userRowId?.takeIf { turn.complete } ?: return + val sessionId = _uiState.value.currentSessionId ?: return + val scope = sendScope() + sendStore + .all() + .filter { + it.scope == scope && it.sessionId == sessionId && it.userRowId == userRowId && + it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + }.forEach { removePendingSend(it.id) } + } + private fun removeUnconfirmedBubble(messageId: String) { val row = _uiState.value.messages.firstOrNull { it.id == messageId && it.canonicalRestId == null } ?: return _uiState.update { state -> state.copy(messages = state.messages.filterNot { it.id == row.id }) } @@ -3634,6 +3704,10 @@ class ChatViewModel( handleUndoCommand(result.count) } + is SlashResult.Compress -> { + compressSession(result.focusTopic) + } + is SlashResult.RpcDispatch -> { dispatchViaRpc(command) } @@ -3903,9 +3977,8 @@ class ChatViewModel( } /** - * Fork the active conversation via the session.branch WS RPC (issue #533). - * The backend already supports session.branch; the mobile previously had - * no client surface, so `/fork` fell through to command.dispatch and 4018'd. + * Fork the active conversation via the lightweight session.branch_whole WS RPC (issue #1289). + * Falls back to session.branch if the gateway answers -32601 (unknown method). * The optional arg becomes the new branch's title. */ private fun branchSession(command: String) { @@ -3920,9 +3993,12 @@ class ChatViewModel( val generation = sessionGeneration viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_BRANCH, + WsMethods.SESSION_BRANCH_WHOLE, params, - onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_BRANCH, generation) }, + onSent = { id -> + branchWholeRequests[id] = PendingBranchRequest(generation, params) + trackSessionRequest(id, WsMethods.SESSION_BRANCH_WHOLE, generation) + }, ) } } @@ -4056,6 +4132,113 @@ class ChatViewModel( } } + private fun compressSession(focusTopic: String) { + val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId + if (sessionId == null) { + addAssistantMessage("No active session to compress.") + return + } + if (_uiState.value.isCompressing) { + return + } + + _uiState.update { + it.copy( + isCompressing = true, + compressionStatus = "⏳ Compressing context...", + ) + } + + viewModelScope.launch(ioDispatcher) { + try { + val params = + buildMap { + put("session_id", sessionId) + if (focusTopic.isNotBlank()) { + put("focus_topic", focusTopic) + } + } + // Use extended timeout (e.g. 300_000L / 5 minutes) so LLM summarization doesn't timeout + val result = + wsClient + .request( + WsMethods.SESSION_COMPRESS, + params, + timeoutMs = 300_000L, + ).await() + + handleCompressionResult(result) + } catch (e: HermesWsClient.HermesRpcException) { + addAssistantMessage("/compress: ${e.message ?: "compression failed"}") + } catch (e: Exception) { + addAssistantMessage("/compress: ${e.message ?: "compression failed"}") + } finally { + _uiState.update { + it.copy( + isCompressing = false, + compressionStatus = null, + ) + } + } + } + } + + private suspend fun handleCompressionResult(result: Any?) { + val response = + try { + val jsonElement = result.toJsonElement() + OkHttpProvider.json.decodeFromJsonElement(jsonElement) + } catch (e: Exception) { + Log.e(TAG, "Failed to decode session.compress response", e) + null + } + + val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId + + // 1. Authoritative transcript replacement + if (response?.messages != null && sessionId != null) { + val replacementMessages = + withContext(historyDispatcher) { + mapServerMessages( + sessionId = sessionId, + messages = response.messages, + offset = 0, + latestPaging = latestPaging, + liveMessages = emptyList(), + context = getApplication(), + ) + } + _uiState.update { it.copy(messages = replacementMessages) } + persistHistoryPage(replacementMessages, sessionId) + } + + // 2. Display result summary via addAssistantMessage + val summary = response?.summary + val lines = mutableListOf() + summary?.headline?.takeIf { it.isNotBlank() }?.let { lines.add(it) } + summary?.token_line?.takeIf { it.isNotBlank() }?.let { lines.add(it) } + summary?.note?.takeIf { it.isNotBlank() }?.let { lines.add(it) } + + val feedback = + if (lines.isNotEmpty()) { + lines.joinToString("\n") + } else { + response?.message ?: "Context compressed." + } + addAssistantMessage(feedback) + + // 3. Refresh usage & sessions + fetchContextUsage() + loadSessions() + + // 4. Handle lineage/session info if provided + response?.info?.let { infoElement -> + val infoMap = (infoElement.toAny() as? Map<*, *>)?.filterKeys { it is String } as? Map + handleSessionInfo(infoMap) + } + sessionHasServerPresence = true + } + private fun handlePrefillResult( message: String, notice: String, @@ -4648,6 +4831,7 @@ class ChatViewModel( content = it.text, attachments = it.attachments, timestamp = it.createdAt, + serverRowId = it.userRowId, ) } } @@ -5968,6 +6152,7 @@ class ChatViewModel( private fun forgetRequest(id: String) { idToMethod.remove(id) sessionRequestById.remove(id) + branchWholeRequests.remove(id) } // ── Search ──────────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 28d248be..1a0c983b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -208,7 +208,7 @@ object ChatWsEventReducer { is WsEvent.SessionUpdated -> onSessionUpdated(state, streamingState) - is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState) + is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState, event) is WsEvent.ConnectionRequest, is WsEvent.ConnectionUpdate, @@ -515,6 +515,9 @@ object ChatWsEventReducer { effects = effects, ) } + // #1285: bind the committed final row only when this bubble carries the whole reply body; + // a stripped commentary prefix means the local bubble is not that row's exact content. + val finalRowId = parsePersistedTurn(event.rawPayload)?.finalAssistantRowId?.takeIf { text == event.text } val tps = turnUsage?.avgTps.validTpsOrNull() ?: usageState.latestTps.validTpsOrNull() val tokenCount = if (turnUsage?.outputTokens != null) { @@ -531,6 +534,7 @@ object ChatWsEventReducer { tokenCount = tokenCount, tps = tps, completionId = event.completionId, + serverRowId = finalRowId ?: streaming.serverRowId, ) ?: ChatMessage( role = MessageRole.ASSISTANT, content = text, @@ -539,6 +543,7 @@ object ChatWsEventReducer { tokenCount = tokenCount, tps = tps, completionId = event.completionId, + serverRowId = finalRowId, ) val effects = mutableListOf() val sid = state.currentSessionId @@ -1041,7 +1046,38 @@ object ChatWsEventReducer { private fun onStatusUpdate( state: ChatUiState, streamingState: StreamingState, - ): ReducerResult = ReducerResult(state = state, streamingState = streamingState) + event: WsEvent.StatusUpdate, + ): ReducerResult { + val kind = event.data?.get("kind") as? String + val text = event.data?.get("text") as? String + return when (kind) { + "compressing", "compacting" -> { + ReducerResult( + state = + state.copy( + isCompressing = true, + compressionStatus = text ?: "⏳ Compressing context...", + ), + streamingState = streamingState, + ) + } + + "compacted" -> { + ReducerResult( + state = + state.copy( + isCompressing = false, + compressionStatus = text, + ), + streamingState = streamingState, + ) + } + + else -> { + ReducerResult(state = state, streamingState = streamingState) + } + } + } private fun onUnknown( state: ChatUiState, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt index 31712655..c1aba996 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -10,7 +10,8 @@ package com.m57.hermescontrol.ui.chat * `/fork` and `/model` are NOT sent via [SlashResult.RpcDispatch] (which maps * to the `command.dispatch` RPC — that RPC only knows quick/plugin/bundle/skill * commands and 4018s on everything else). They are real backend commands that - * get their own results: `/fork` goes via the `session.branch` RPC and `/model` + * get their own results: `/fork` goes via the `session.branch_whole` RPC (with + * fallback to `session.branch` on older gateways) and `/model` * via the `config.set` RPC (key="model" → gateway `_apply_model_switch`; the * TUI gateway's `prompt.submit` does NOT parse slash commands, so sending it as * a normal prompt makes the LLM treat it as text). `/update` is intercepted @@ -76,6 +77,11 @@ class SlashCommandDispatcher { SlashResult.Undo(count = arg) } + "/compress", "/compact" -> { + val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() + SlashResult.Compress(focusTopic = arg) + } + else -> { SlashResult.RpcDispatch } @@ -171,4 +177,11 @@ sealed class SlashResult { data class Undo( val count: String, ) : SlashResult() + + /** + * Compress session context via the session.compress WebSocket RPC. + */ + data class Compress( + val focusTopic: String, + ) : SlashResult() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt index 3eb0c605..8e09d120 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -179,7 +179,7 @@ internal fun ToolBubble( .padding(horizontal = 10.dp, vertical = 6.dp), ) { // ── Header row: icon + tool name ── - HeaderRow(message, config, contentColor, statusColors) + HeaderRow(message, config, contentColor, statusColors, view?.serverDisplayName) // ── Tool progress preview (tool.progress) ── if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) { @@ -528,6 +528,7 @@ private fun HeaderRow( config: ToolDisplayConfig, contentColor: Color, statusColors: HermesStatusColors, + displayName: String? = null, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -562,7 +563,7 @@ private fun HeaderRow( } Text( - text = message.toolName ?: stringResource(R.string.chat_tool_fallback), + text = displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback), style = MaterialTheme.typography.labelMedium.copy( color = contentColor, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt index 24638217..0d12fb09 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.ui.chat.tool.ToolView import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -42,8 +43,31 @@ fun parseToolOutput( // Extract args sub-object if present (new tool.complete format) and // the result sub-object, falling back to the top-level payload (old // format where result fields sat at the root). - val args = element["args"] as? JsonObject - val result = element["result"] as? JsonObject ?: element + val rawArgs = element["args"] as? JsonObject + // Gateway labels are envelope metadata; never interpret a real args.labels value as display text. + val labels = element["labels"] as? JsonArray + val args = + if (labels != null) { + JsonObject(rawArgs.orEmpty() + ("hermes_tool_labels" to labels)) + } else { + rawArgs + } + val rawResultObj = element["result"] as? JsonObject + val displayMetadata = element["display_metadata"] + val result = + when { + rawResultObj != null && displayMetadata != null && !rawResultObj.containsKey("display_metadata") -> { + JsonObject(rawResultObj + ("display_metadata" to displayMetadata)) + } + + rawResultObj != null -> { + rawResultObj + } + + else -> { + element + } + } ToolViewBuilder.build( toolName = resolvedToolName ?: "tool", diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeech.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeech.kt new file mode 100644 index 00000000..1b7c046c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeech.kt @@ -0,0 +1,288 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.Context +import android.net.Uri +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import com.m57.hermescontrol.data.model.TtsSpeakRequest +import com.m57.hermescontrol.data.remote.HermesApiService +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.util.Base64 +import java.util.UUID + +private const val TTS_CACHE_DIR_NAME = "speech_tts" +private const val TTS_FILE_PREFIX = "tts_" +private const val TTS_STALE_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000L // 24 hours + +class FileSpeechAudio( + val file: File, +) : SpeechAudio { + override fun dispose() { + runCatching { file.delete() } + } +} + +/** + * Android implementation of [SpeechSynthesizer] backed by Hermes TTS REST API. + * + * Audio is decoded from base64 data URLs directly to temporary files in application cache. + * Stale orphan cache files (created in previous sessions or left behind by unexpected crashes) + * older than [TTS_STALE_CACHE_MAX_AGE_MS] are opportunistically pruned during synthesis on the + * IO dispatcher before creating new files. + * + * Limitation note: Single-active controller usage is expected. Active audio files in current use + * by the active session are protected from cleanup by the age threshold, but concurrent multi-process + * or out-of-band manipulation of cache files is not synchronized. + */ +class AndroidSpeechSynthesizer( + context: Context, + private val api: HermesApiService, + private val profile: String?, +) : SpeechSynthesizer { + private val appContext = context.applicationContext + + override suspend fun synthesize(request: SpeechRequest): SpeechAudio { + val response = + api.speakText( + request = TtsSpeakRequest(request.text), + profile = profile, + ) + + if (!response.isSuccessful) { + throw IOException("TTS request failed with code ${response.code()}") + } + + val body = response.body() ?: throw IOException("Empty TTS response body") + if (!body.ok) { + throw IOException("TTS synthesis reported failure") + } + + val dataUrl = body.data_url + if (dataUrl.isNullOrBlank()) { + throw IOException("Missing data URL in TTS response") + } + + var ownedFile: File? = null + try { + val audioFile = + withContext(Dispatchers.IO) { + val comma = dataUrl.indexOf(',') + if (comma < 0) { + throw IOException("Malformed data URL in TTS response") + } + + val meta = dataUrl.substring(0, comma) + if (!meta.startsWith("data:audio/", ignoreCase = true)) { + throw IOException("Expected audio data URL") + } + if (!meta.endsWith(";base64", ignoreCase = true)) { + throw IOException("Expected base64-encoded audio data URL") + } + + val ext = + when { + meta.contains( + "audio/mpeg", + ignoreCase = true, + ) || meta.contains("audio/mp3", ignoreCase = true) -> "mp3" + + meta.contains("audio/wav", ignoreCase = true) -> "wav" + + meta.contains("audio/ogg", ignoreCase = true) -> "ogg" + + meta.contains("audio/aac", ignoreCase = true) -> "aac" + + meta.contains("audio/opus", ignoreCase = true) -> "opus" + + meta.contains("audio/flac", ignoreCase = true) -> "flac" + + else -> "audio" + } + + val cacheDir = File(appContext.cacheDir, TTS_CACHE_DIR_NAME).apply { mkdirs() } + cleanStaleCacheFiles(cacheDir) + + val file = File(cacheDir, "${TTS_FILE_PREFIX}${UUID.randomUUID()}.$ext") + ownedFile = file + + val rawStream = CharSequenceInputStream(dataUrl, comma + 1) + val decodingStream = Base64.getDecoder().wrap(rawStream) + + var totalBytesWritten = 0L + decodingStream.use { input -> + FileOutputStream(file).use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + currentCoroutineContext().ensureActive() + output.write(buffer, 0, bytesRead) + totalBytesWritten += bytesRead + } + output.flush() + } + } + + // Java's Base64.getDecoder().wrap() stops decoding upon encountering padding ('='). + // Verify that the underlying stream contains no unparsed trailing non-whitespace characters. + if (rawStream.read() != -1) { + throw IOException("Trailing garbage characters found after base64 data") + } + + if (totalBytesWritten == 0L) { + throw IOException("Decoded audio file is empty") + } + + file + } + + ownedFile = null + return FileSpeechAudio(audioFile) + } catch (e: CancellationException) { + ownedFile?.let { runCatching { it.delete() } } + throw e + } catch (t: Throwable) { + ownedFile?.let { runCatching { it.delete() } } + throw t + } + } + + private fun cleanStaleCacheFiles(cacheDir: File) { + val now = System.currentTimeMillis() + val files = + cacheDir.listFiles { _, name -> + name.startsWith(TTS_FILE_PREFIX) + } ?: return + + for (file in files) { + if (now - file.lastModified() > TTS_STALE_CACHE_MAX_AGE_MS) { + runCatching { file.delete() } + } + } + } + + private class CharSequenceInputStream( + private val value: CharSequence, + startIndex: Int, + ) : InputStream() { + private var index = startIndex + + override fun read(): Int { + while (index < value.length) { + val c = value[index++] + if (!c.isWhitespace()) { + if (c.code > 127) { + throw IOException("Non-ASCII character in base64 data") + } + return c.code + } + } + return -1 + } + + override fun read( + b: ByteArray, + off: Int, + len: Int, + ): Int { + if (len == 0) return 0 + var bytesRead = 0 + while (bytesRead < len) { + val c = read() + if (c == -1) { + return if (bytesRead == 0) -1 else bytesRead + } + b[off + bytesRead] = c.toByte() + bytesRead++ + } + return bytesRead + } + } +} + +class AndroidSpeechPlayer( + context: Context, +) : SpeechPlayer { + private val appContext = context.applicationContext + private var exoPlayer: ExoPlayer? = null + private var activeListener: Player.Listener? = null + + override fun play( + audio: SpeechAudio, + onEnded: () -> Unit, + onError: () -> Unit, + ) { + if (audio !is FileSpeechAudio) { + onError() + return + } + + val file = audio.file + if (!file.exists() || file.length() == 0L) { + onError() + return + } + + val player = + exoPlayer ?: ExoPlayer + .Builder(appContext) + .setAudioAttributes( + AudioAttributes + .Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) + .setUsage(C.USAGE_MEDIA) + .build(), + true, + ).setHandleAudioBecomingNoisy(true) + .build() + .also { exoPlayer = it } + + activeListener?.let { player.removeListener(it) } + + val listener = + object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_ENDED) { + player.removeListener(this) + activeListener = null + onEnded() + } + } + + override fun onPlayerError(error: PlaybackException) { + player.removeListener(this) + activeListener = null + onError() + } + } + + activeListener = listener + player.addListener(listener) + + val mediaItem = MediaItem.fromUri(Uri.fromFile(file)) + player.setMediaItem(mediaItem) + player.prepare() + player.playWhenReady = true + } + + override fun stop() { + val player = exoPlayer ?: return + activeListener?.let { player.removeListener(it) } + activeListener = null + player.stop() + player.clearMediaItems() + player.release() + exoPlayer = null + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index ea3651f2..2a49d067 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.chat.components +import android.content.Context import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically @@ -9,6 +10,7 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -16,11 +18,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -30,6 +35,7 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -47,9 +53,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -62,7 +73,6 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment -import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.ws.CommandBlocklist import com.m57.hermescontrol.data.ws.CommandCatalog @@ -79,11 +89,9 @@ fun ChatInputBar( inputFieldValue: TextFieldValue, onInputChange: (TextFieldValue) -> Unit, onSend: () -> Unit, - onBusySend: (BusySendMode) -> Unit = {}, onMicTap: () -> Unit, isListening: Boolean, isAgentTyping: Boolean, - isMainTurnBusy: Boolean = false, canInterrupt: Boolean = false, isConnected: Boolean, commandCatalog: CommandCatalog, @@ -113,8 +121,9 @@ fun ChatInputBar( onMicHoldStart: () -> Unit = {}, onMicHoldEnd: () -> Unit = {}, onMicHoldCancel: () -> Unit = {}, + onMicLock: () -> Unit = {}, + isVoiceNoteLocked: Boolean = false, isRecordingVoice: Boolean = false, - voiceNoteAmplitude: State = remember { mutableStateOf(0f) }, onStopGeneration: () -> Unit = {}, ) { // Allow sending while the agent is mid-turn or awaiting approval: the @@ -125,265 +134,408 @@ fun ChatInputBar( pendingReasoningLevel == null && ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady) val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty() - val isSlashDraft = inputFieldValue.text.trimStart().startsWith("/") - val showBusyActions = isMainTurnBusy && hasDraft && !isSlashDraft // Attachment tray state var showAttachmentTray by remember { mutableStateOf(false) } + // The voice-note strip replaces the input field, so the IME closes the + // moment recording starts. When the strip goes away (send, delete, or + // cancel), focus returns to the input and the keyboard re-opens if it was + // up before — the next message must not need an extra tap (device + // follow-up, #1247). + val inputFocusRequester = remember { FocusRequester() } + val softwareKeyboard = LocalSoftwareKeyboardController.current + var inputWasFocused by remember { mutableStateOf(false) } + var restoreInputFocus by remember { mutableStateOf(false) } + // Snapshot BEFORE the launcher flips isRecordingVoice — by the time the + // recomposition swaps the field out, focus is already on its way to the + // root, so the effect alone cannot read it reliably. + val handleMicHoldStart = { + restoreInputFocus = inputWasFocused + onMicHoldStart() + } + // A hold can arm and still fail to start the recorder (permission denied, + // mic busy). Drop the focus snapshot then, so the end of some later + // recording cannot pop the keyboard on its behalf (review, PR #1280). + val handleMicHoldEnd = { + if (!isRecordingVoice) restoreInputFocus = false + onMicHoldEnd() + } + val handleMicHoldCancel = { + if (!isRecordingVoice) restoreInputFocus = false + onMicHoldCancel() + } + LaunchedEffect(isRecordingVoice) { + if (!isRecordingVoice && restoreInputFocus) { + restoreInputFocus = false + inputFocusRequester.requestFocus() + softwareKeyboard?.show() + } + } + // Telegram parity: the strip's hint slides and fades with the drag, so + // the gesture reports its 1 → 0 cancel fraction here. + val voiceSlideProgress = remember { mutableStateOf(1f) } + // The "Slide up to lock recording" tooltip shows until the first + // successful lock, then stays away (persisted, like Telegram). + val context = LocalContext.current + var voiceLockHintDone by remember { + mutableStateOf( + context + .getSharedPreferences(VOICE_PREFS_NAME, Context.MODE_PRIVATE) + .getBoolean(VOICE_LOCK_HINT_KEY, false), + ) + } + LaunchedEffect(isVoiceNoteLocked) { + if (isVoiceNoteLocked && !voiceLockHintDone) { + voiceLockHintDone = true + context + .getSharedPreferences(VOICE_PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(VOICE_LOCK_HINT_KEY, true) + .apply() + } + } val palette = composerPalette() BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false } - AnimatedVisibility( - visible = true, - enter = slideInVertically(initialOffsetY = { it }), - exit = slideOutVertically(targetOffsetY = { it }), - ) { - // One floating card holds the whole composer: suggestions, attachments, - // the input and the controls row. Flat fill, hairline edge, no shadow. - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - shape = MaterialTheme.shapes.large, - color = palette.card, - border = BorderStroke(width = 1.dp, color = palette.cardBorder), + Box { + AnimatedVisibility( + visible = true, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), ) { - Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) { - // Commands hidden from the suggestion menu — desktop/CLI-only and - // TUI-only commands that don't function on mobile (issue #574). - // Single source of truth: CommandBlocklist.UNSUPPORTED, which is - // also enforced at dispatch time (issue #576, deliverable #3). - val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED - val commandNames = - (commandCatalog.pairs.map { it[0] } + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop")) - .distinct() - .filter { it.lowercase() !in hiddenSlashDisplay } + // One floating card holds the whole composer: suggestions, attachments, + // the input and the controls row. Flat fill, hairline edge, no shadow. + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.large, + color = palette.card, + border = BorderStroke(width = 1.dp, color = palette.cardBorder), + ) { + Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) { + // Commands hidden from the suggestion menu — desktop/CLI-only and + // TUI-only commands that don't function on mobile (issue #574). + // Single source of truth: CommandBlocklist.UNSUPPORTED, which is + // also enforced at dispatch time (issue #576, deliverable #3). + val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED + val commandNames = + ( + commandCatalog.pairs.map { it[0] } + + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop") + ).distinct() + .filter { it.lowercase() !in hiddenSlashDisplay } - androidx.compose.animation.AnimatedVisibility( - visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "), - enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(), - exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(), - ) { - val filteredCommands = - ChatInputPolicy.sortSlashSuggestions( - commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) }, - slashUsageCounts, - ) - if (filteredCommands.isNotEmpty()) { - androidx.compose.material3.Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - border = - BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), - ), - ) { - LazyColumn( - modifier = Modifier.heightIn(max = 200.dp), + androidx.compose.animation.AnimatedVisibility( + visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "), + enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(), + exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(), + ) { + val filteredCommands = + ChatInputPolicy.sortSlashSuggestions( + commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) }, + slashUsageCounts, + ) + if (filteredCommands.isNotEmpty()) { + androidx.compose.material3.Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = + BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + ), ) { - items(filteredCommands, key = { it }) { cmd -> - DropdownMenuItem( - text = { - Text( - cmd, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - }, - onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) }, - ) + LazyColumn( + modifier = Modifier.heightIn(max = 200.dp), + ) { + items(filteredCommands, key = { it }) { cmd -> + DropdownMenuItem( + text = { + Text( + cmd, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + }, + onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) }, + ) + } } } } } - } - // Attachment preview chips - AnimatedVisibility( - visible = pendingAttachments.isNotEmpty(), - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - LazyRow( + // Attachment preview chips + AnimatedVisibility( + visible = pendingAttachments.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LazyRow( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed(pendingAttachments) { index, attachment -> + AttachmentChip( + attachment = attachment, + onPreview = { onPreviewAttachment(attachment) }, + onRemove = { onRemoveAttachment(index) }, + ) + } + } + } + + if (isConnected && !isSessionReady) { + Text( + text = + stringResource( + if (sessionPreparationFailed) { + R.string.chat_session_not_ready + } else { + R.string.chat_session_preparing + }, + ), + style = MaterialTheme.typography.labelSmall, + color = palette.placeholder, + modifier = Modifier.padding(horizontal = 20.dp).testTag("chat_session_preparing"), + ) + } + + // ── TOP ROW: Borderless input field ── + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + .padding(horizontal = 20.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, ) { - itemsIndexed(pendingAttachments) { index, attachment -> - AttachmentChip( - attachment = attachment, - onPreview = { onPreviewAttachment(attachment) }, - onRemove = { onRemoveAttachment(index) }, + val placeholderText = + when { + !isConnected -> { + stringResource(R.string.chat_input_placeholder_not_connected) + } + + ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> { + stringResource(R.string.chat_input_placeholder_queue) + } + + isAgentTyping -> { + stringResource(R.string.chat_input_placeholder_waiting) + } + + else -> { + stringResource(R.string.chat_input_placeholder_type_message) + } + } + + val ambientLayoutDirection = LocalLayoutDirection.current + val inputLayoutDirection = + remember(inputFieldValue.text, ambientLayoutDirection) { + BidiUtils.resolveLayoutDirection( + inputFieldValue.text, + fallback = ambientLayoutDirection, + ) + } + val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl + + if (isRecordingVoice) { + VoiceNoteRecordingPanel( + slideProgress = voiceSlideProgress, + locked = isVoiceNoteLocked, + onCancel = onMicHoldCancel, + modifier = Modifier.weight(1f), ) - } - } - } - - if (isConnected && !isSessionReady) { - Text( - text = - stringResource( - if (sessionPreparationFailed) { - R.string.chat_session_not_ready - } else { - R.string.chat_session_preparing - }, - ), - style = MaterialTheme.typography.labelSmall, - color = palette.placeholder, - modifier = Modifier.padding(horizontal = 20.dp).testTag("chat_session_preparing"), - ) - } - - // ── TOP ROW: Borderless input field ── - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val placeholderText = - when { - !isConnected -> { - stringResource(R.string.chat_input_placeholder_not_connected) - } - - ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> { - stringResource(R.string.chat_input_placeholder_queue) - } - - isAgentTyping -> { - stringResource(R.string.chat_input_placeholder_waiting) - } - - else -> { - stringResource(R.string.chat_input_placeholder_type_message) - } - } - - val ambientLayoutDirection = LocalLayoutDirection.current - val inputLayoutDirection = - remember(inputFieldValue.text, ambientLayoutDirection) { - BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection) - } - val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl - - if (isRecordingVoice) { - VoiceNoteRecordingPanel( - amplitude = voiceNoteAmplitude, - modifier = Modifier.weight(1f), - ) - } else { - CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { - BasicTextField( - value = inputFieldValue, - onValueChange = onInputChange, - modifier = - Modifier - .weight(1f) - .heightIn(min = 42.dp, max = 200.dp) - .padding(vertical = 4.dp) - .testTag("chat_input"), - enabled = isConnected, - textStyle = - MaterialTheme.typography.bodyLarge.copy( - color = palette.text, - textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, - textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, - ), - singleLine = false, - maxLines = 8, - cursorBrush = SolidColor(palette.text), - decorationBox = { innerTextField -> - CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = - if (isInputRtl) { - Alignment.CenterEnd - } else { - Alignment.CenterStart - }, - ) { - CompositionLocalProvider( - LocalLayoutDirection provides inputLayoutDirection, + } else { + CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { + BasicTextField( + value = inputFieldValue, + onValueChange = onInputChange, + modifier = + Modifier + .weight(1f) + .heightIn(min = 42.dp, max = 200.dp) + .padding(vertical = 4.dp) + .focusRequester(inputFocusRequester) + .onFocusChanged { focusState -> + if (!isRecordingVoice) { + inputWasFocused = focusState.isFocused + } + }.testTag("chat_input"), + enabled = isConnected, + textStyle = + MaterialTheme.typography.bodyLarge.copy( + color = palette.text, + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + singleLine = false, + maxLines = 8, + cursorBrush = SolidColor(palette.text), + decorationBox = { innerTextField -> + CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = + if (isInputRtl) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, ) { - if (inputFieldValue.text.isEmpty()) { - Text( - text = placeholderText, - style = MaterialTheme.typography.bodyLarge, - textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, - color = palette.placeholder, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) + CompositionLocalProvider( + LocalLayoutDirection provides inputLayoutDirection, + ) { + if (inputFieldValue.text.isEmpty()) { + Text( + text = placeholderText, + style = MaterialTheme.typography.bodyLarge, + textAlign = + if (isInputRtl) TextAlign.Right else TextAlign.Left, + color = palette.placeholder, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + innerTextField() } - innerTextField() } } - } - }, - ) + }, + ) + } } } + + // ── BOTTOM ROW: Toolbar ── + ComposerToolbar( + isConnected = isConnected, + currentSessionModel = currentSessionModel, + reasoningLevel = reasoningLevel, + isListening = isListening, + canSend = canSend, + showSend = hasDraft, + onSend = onSend, + canInterrupt = canInterrupt, + onStopGeneration = onStopGeneration, + onAttachTap = { showAttachmentTray = !showAttachmentTray }, + onModelTap = onModelTap, + onReasoningSelected = onReasoningTap, + onMicTap = onMicTap, + onMicHoldStart = handleMicHoldStart, + onMicHoldEnd = handleMicHoldEnd, + onMicHoldCancel = handleMicHoldCancel, + onMicLock = onMicLock, + isVoiceNoteLocked = isVoiceNoteLocked, + onSlideProgress = { voiceSlideProgress.value = it }, + modifier = Modifier.testTag("chat_composer_toolbar"), + canDisableReasoning = canDisableReasoning, + supportsReasoning = supportsReasoning, + fastMode = fastMode, + fastSupported = fastSupported, + isFastModeChanging = isFastModeChanging, + onToggleFastMode = onToggleFastMode, + showModelProvider = showModelProvider, + reasoningWireLevel = reasoningWireLevel, + pendingReasoningLevel = pendingReasoningLevel, + isSessionReady = isSessionReady, + ) + + AttachmentTray( + visible = showAttachmentTray, + onDismissRequest = { showAttachmentTray = false }, + onCameraTap = onCameraTap, + onImageTap = onImageTap, + onFileTap = onFileTap, + ) } - - // ── BOTTOM ROW: Toolbar ── - ComposerToolbar( - isConnected = isConnected, - currentSessionModel = currentSessionModel, - reasoningLevel = reasoningLevel, - isListening = isListening, - canSend = canSend, - showSend = hasDraft, - showQueue = showBusyActions, - onSend = onSend, - onQueue = { onBusySend(BusySendMode.QUEUE) }, - onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) }, - canInterrupt = canInterrupt, - onStopGeneration = onStopGeneration, - onAttachTap = { showAttachmentTray = !showAttachmentTray }, - onModelTap = onModelTap, - onReasoningSelected = onReasoningTap, - onMicTap = onMicTap, - onMicHoldStart = onMicHoldStart, - onMicHoldEnd = onMicHoldEnd, - onMicHoldCancel = onMicHoldCancel, - modifier = Modifier.testTag("chat_composer_toolbar"), - canDisableReasoning = canDisableReasoning, - supportsReasoning = supportsReasoning, - fastMode = fastMode, - fastSupported = fastSupported, - isFastModeChanging = isFastModeChanging, - onToggleFastMode = onToggleFastMode, - showModelProvider = showModelProvider, - reasoningWireLevel = reasoningWireLevel, - pendingReasoningLevel = pendingReasoningLevel, - isSessionReady = isSessionReady, - ) - - AttachmentTray( - visible = showAttachmentTray, - onDismissRequest = { showAttachmentTray = false }, - onCameraTap = onCameraTap, - onImageTap = onImageTap, - onFileTap = onFileTap, - ) } } + // Zero-height anchor so the tooltip and lock icon float above the + // card, over the message list, like Telegram's lock control. It sits + // outside the animated card because AnimatedVisibility clips its + // children to the animation bounds. + Box(modifier = Modifier.fillMaxWidth().height(0.dp)) { + VoiceNoteLockHintOverlay( + visible = isRecordingVoice && !isVoiceNoteLocked, + showTextHint = !voiceLockHintDone && voiceSlideProgress.value >= 0.8f, + modifier = + Modifier + .wrapContentHeight(align = Alignment.Top, unbounded = true) + .align(Alignment.TopEnd) + .offset(y = (-42).dp) + .padding(end = 12.dp), + ) + } } } +/** + * Telegram-style lock affordance floating over the composer while a voice + * note records: the outlined lock icon with the "Slide up to lock recording" + * tooltip beside it. The tooltip shows only until the first successful lock + * (persisted in [VOICE_PREFS_NAME]); the icon rides along until the recording + * locks hands-free. + */ +@Composable +private fun VoiceNoteLockHintOverlay( + visible: Boolean, + showTextHint: Boolean, + modifier: Modifier = Modifier, +) { + if (!visible) { + return + } + val palette = composerPalette() + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + if (showTextHint) { + Text( + text = stringResource(R.string.chat_voice_lock_hint), + style = MaterialTheme.typography.labelSmall, + color = palette.text, + maxLines = 1, + modifier = + Modifier + .background(palette.card, RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + } + Box( + modifier = + Modifier + .size(36.dp) + .background(palette.control, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = palette.placeholder, + modifier = Modifier.size(18.dp), + ) + } + } +} + +/** Shared prefs file for the voice-note UI. */ +private const val VOICE_PREFS_NAME = "chat_voice_prefs" + +/** Set after the first successful hands-free lock, silencing the hint. */ +private const val VOICE_LOCK_HINT_KEY = "voice_note_lock_hint_done" + /** * Reusable attachment chip composable for showing a pending attachment * with a remove button. diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt index fe8dd7d8..1d8aa22f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt @@ -14,7 +14,6 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -40,7 +39,7 @@ import java.util.Locale class ChatMediaLaunchers( val isListening: Boolean, val isRecordingVoice: Boolean, - val voiceNoteAmplitude: State, + val isVoiceNoteLocked: Boolean, val onMicTap: () -> Unit, val onCameraTap: () -> Unit, val onImageTap: () -> Unit, @@ -48,6 +47,7 @@ class ChatMediaLaunchers( val onMicHoldStart: () -> Unit = {}, val onMicHoldEnd: () -> Unit = {}, val onMicHoldCancel: () -> Unit = {}, + val onMicLock: () -> Unit = {}, ) @Composable @@ -85,28 +85,14 @@ fun rememberChatMediaLaunchers( val voiceNoteRecorder = remember { VoiceNoteRecorder(context) } var isRecordingVoice by remember { mutableStateOf(false) } - // Live mic level for the recording panel — rises fast, decays slowly so - // the meter reads as voice activity instead of flicker. - val voiceNoteAmplitude = remember { mutableStateOf(0f) } - LaunchedEffect(Unit) { - while (true) { - delay(90) - val previous = voiceNoteAmplitude.value - val next = - if (voiceNoteRecorder.isActive) { - maxOf(voiceNoteRecorder.currentAmplitude() / 32767f, previous * 0.8f) - } else { - 0f - } - if (next != previous) { - voiceNoteAmplitude.value = next - } - } - } + // Locked = the recording continues after the finger lifts (slide up + // during the hold); the action button then submits it. + var isVoiceNoteLocked by remember { mutableStateOf(false) } fun finishVoiceRecording() { val recordedFile = voiceNoteRecorder.stop() isRecordingVoice = false + isVoiceNoteLocked = false if (recordedFile != null) { currentOnVoiceNoteRecorded(recordedFile) } @@ -264,6 +250,9 @@ fun rememberChatMediaLaunchers( if (isTranscribingVoiceNote) { // Single-flight: a server transcription owns the voice pipeline // until it lands (review, PR #1250). + } else if (isVoiceNoteLocked) { + // The locked recording's action button submits the note. + finishVoiceRecording() } else if (isRecordingVoice) { // A tap while recording discards the in-flight voice note. voiceNoteRecorder.cancel() @@ -334,6 +323,15 @@ fun rememberChatMediaLaunchers( voiceNoteRecorder.cancel() } isRecordingVoice = false + isVoiceNoteLocked = false + } + + // Locking keeps the recorder running once the finger lifts; only the + // panel's delete action or the action button can end it then. + val onMicLock: () -> Unit = { + if (voiceNoteRecorder.isActive) { + isVoiceNoteLocked = true + } } val onCameraTap: () -> Unit = { @@ -369,11 +367,11 @@ fun rememberChatMediaLaunchers( } } - return remember(isListening, isRecordingVoice, isTranscribingVoiceNote) { + return remember(isListening, isRecordingVoice, isVoiceNoteLocked, isTranscribingVoiceNote) { ChatMediaLaunchers( isListening = isListening || isRecordingVoice, isRecordingVoice = isRecordingVoice, - voiceNoteAmplitude = voiceNoteAmplitude, + isVoiceNoteLocked = isVoiceNoteLocked, onMicTap = onMicTap, onCameraTap = onCameraTap, onImageTap = onImageTap, @@ -381,6 +379,7 @@ fun rememberChatMediaLaunchers( onMicHoldStart = onMicHoldStart, onMicHoldEnd = onMicHoldEnd, onMicHoldCancel = onMicHoldCancel, + onMicLock = onMicLock, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index 9fcd992b..6b47a49c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.AddToQueue import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Stop @@ -61,13 +60,15 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.scale import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.CustomAccessibilityAction 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 @@ -109,10 +110,7 @@ fun ComposerToolbar( modifier: Modifier = Modifier, canSend: Boolean = false, showSend: Boolean = canSend, - showQueue: Boolean = false, onSend: () -> Unit = {}, - onQueue: () -> Unit = {}, - onStopAndSend: () -> Unit = {}, canDisableReasoning: Boolean? = null, supportsReasoning: Boolean? = null, fastMode: Boolean = false, @@ -127,6 +125,9 @@ fun ComposerToolbar( onMicHoldStart: () -> Unit = {}, onMicHoldEnd: () -> Unit = {}, onMicHoldCancel: () -> Unit = {}, + onMicLock: () -> Unit = {}, + isVoiceNoteLocked: Boolean = false, + onSlideProgress: (Float) -> Unit = {}, onStopGeneration: () -> Unit = {}, ) { var showReasoningMenu by remember { mutableStateOf(false) } @@ -134,36 +135,74 @@ fun ComposerToolbar( val reasoningDisabledForModel = supportsReasoning == false val canDisable = canDisableReasoning - // Telegram-style voice notes: hold to record, release to send, slide away - // to cancel. Both mic controls run the same single pointer loop - // (micHoldHandler), which owns the whole press until finger-up — so the - // release that submits the note can never be lost between recompositions. + // Telegram-style voice notes: hold to record, release to send, slide left + // to cancel, slide up to lock hands-free. Both mic controls run the same + // single pointer loop (micHoldHandler), which owns the whole press until + // finger-up — so the release that submits the note can never be lost + // between recompositions. val currentIsConnected = rememberUpdatedState(isConnected) val currentShowSend = rememberUpdatedState(showSend) val currentCanInterrupt = rememberUpdatedState(canInterrupt) + val currentIsVoiceNoteLocked = rememberUpdatedState(isVoiceNoteLocked) val currentOnMicHoldStart = rememberUpdatedState(onMicHoldStart) val currentOnMicHoldEnd = rememberUpdatedState(onMicHoldEnd) val currentOnMicHoldCancel = rememberUpdatedState(onMicHoldCancel) + val currentOnMicLock = rememberUpdatedState(onMicLock) + val currentOnSlideProgress = rememberUpdatedState(onSlideProgress) + // The mic button is under the thumb during the gesture, so the outcome — + // lock or cancel — must be felt as well as seen. + val haptic = LocalHapticFeedback.current + // Telegram's slide-to-cancel span: a third of the composer width, capped + // at [MAX_CANCEL_DISTANCE_DP]. The gesture modifier sits on the mic button + // itself (only ~40 dp wide), so the span derives from the screen width. + val cancelDistancePx = + with(LocalDensity.current) { + minOf( + LocalConfiguration.current.screenWidthDp.dp + .toPx() * 0.35f, + MAX_CANCEL_DISTANCE_DP.dp.toPx(), + ) + } val flatMicGesture = Modifier.pointerInput(Unit) { micHoldHandler( isEnabled = { - currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value + currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value && + !currentIsVoiceNoteLocked.value }, onHoldStart = { currentOnMicHoldStart.value() }, onHoldEnd = { currentOnMicHoldEnd.value() }, - onHoldCancel = { currentOnMicHoldCancel.value() }, + onHoldCancel = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + currentOnMicHoldCancel.value() + }, + onHoldLock = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + currentOnMicLock.value() + }, + onSlideProgress = { currentOnSlideProgress.value(it) }, + cancelDistance = cancelDistancePx, ) } val actionMicGesture = Modifier.pointerInput(Unit) { micHoldHandler( isEnabled = { - currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value + currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value && + !currentIsVoiceNoteLocked.value }, onHoldStart = { currentOnMicHoldStart.value() }, onHoldEnd = { currentOnMicHoldEnd.value() }, - onHoldCancel = { currentOnMicHoldCancel.value() }, + onHoldCancel = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + currentOnMicHoldCancel.value() + }, + onHoldLock = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + currentOnMicLock.value() + }, + onSlideProgress = { currentOnSlideProgress.value(it) }, + cancelDistance = cancelDistancePx, ) } @@ -488,46 +527,47 @@ fun ComposerToolbar( ) } } else { - FilledIconButton( - onClick = onMicTap, - enabled = isConnected, - colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette), + // Plain Box, not FilledIconButton: the hold gesture must sit + // innermost in the pointer-modifier chain so it consumes the + // release before the tap handler — impossible while the button + // supplies its own clickable (review, PR #1280). Same + // arrangement as the action button below. + val micColors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette) + Box( modifier = Modifier .size(ControlSize) - .testTag(if (isListening) "mic_stop_button" else "mic_button") + .clip(CircleShape) + .background( + if (isConnected) micColors.containerColor else micColors.disabledContainerColor, + ).combinedClickable( + enabled = isConnected, + onClick = onMicTap, + ).testTag(if (isListening) "mic_stop_button" else "mic_button") .then(flatMicGesture), + contentAlignment = Alignment.Center, ) { Icon( imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic, contentDescription = if (isListening) "Stop listening" else "Mic", + tint = if (isConnected) micColors.contentColor else micColors.disabledContentColor, ) } } } - AnimatedVisibility( - visible = showQueue, - enter = fadeIn() + scaleIn(initialScale = 0.8f), - exit = fadeOut() + scaleOut(targetScale = 0.8f), - ) { - FilledIconButton( - onClick = onQueue, - enabled = canSend, - colors = flatIconButtonColors(palette), - modifier = Modifier.size(48.dp).testTag("queue_button"), - ) { - Icon( - imageVector = Icons.Default.AddToQueue, - contentDescription = stringResource(R.string.chat_busy_queue_action), - modifier = Modifier.size(20.dp), - ) - } - } - // Action button — send when a send is possible, mic / stop otherwise - val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send) - val actionEnabled = if (showSend) canSend else isConnected + // While a recording is locked this button finishes the voice note, so + // it must stay enabled even when the draft alone would not send + // (review, PR #1280). + val actionEnabled = + if (isVoiceNoteLocked) { + true + } else if (showSend) { + canSend + } else { + isConnected + } Box( modifier = Modifier @@ -539,29 +579,25 @@ fun ComposerToolbar( } else { palette.action }, - ).semantics { - if (showSend && showQueue && canSend) { - customActions = - listOf( - CustomAccessibilityAction(stopAndSendLabel) { - onStopAndSend() - true - }, - ) - } - }.combinedClickable( + ).combinedClickable( enabled = actionEnabled, onClick = { when { + // A locked recording always finishes with this + // button, draft or agent state regardless + // (review, PR #1280). + isVoiceNoteLocked -> onMicTap() + canInterrupt -> onStopGeneration() + showSend -> onSend() + else -> onMicTap() } }, - onLongClick = if (showSend && showQueue) onStopAndSend else null, - onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null, ).testTag( when { + isVoiceNoteLocked -> "voice_note_send_button" canInterrupt -> "stop_button" showSend -> "send_button" isListening -> "mic_stop_button" @@ -573,6 +609,7 @@ fun ComposerToolbar( Crossfade( targetState = when { + isVoiceNoteLocked -> ActionGlyph.SEND canInterrupt -> ActionGlyph.STOP showSend -> ActionGlyph.SEND isListening -> ActionGlyph.STOP @@ -584,7 +621,12 @@ fun ComposerToolbar( ActionGlyph.SEND -> { Icon( imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.chat_send_desc), + contentDescription = + if (isVoiceNoteLocked) { + stringResource(R.string.chat_voice_send_desc) + } else { + stringResource(R.string.chat_send_desc) + }, tint = palette.onAction, ) } @@ -720,36 +762,53 @@ fun buildReasoningLabel( /** * Telegram-style mic gesture: press and hold past [HOLD_TO_RECORD_THRESHOLD_MS] - * records a voice note, release sends it, and sliding away (left or up) from - * the button cancels instead of sending. Short taps use the control's normal - * click handler for dictation, so touch and accessibility actions share one - * callback path. + * records a voice note, release sends it, dragging into the cancel zone + * cancels, and sliding up past [MIC_SLIDE_LOCK_DP] locks the recording + * hands-free. Short taps use the control's normal click handler for dictation, + * so touch and accessibility actions share one callback path. + * + * Distances and outcomes mirror Telegram's recorder: the full cancel distance + * is a third of the composer capped at [MAX_CANCEL_DISTANCE_DP]; releasing + * below [CANCEL_RELEASE_ALPHA] of it cancels (dragging back out before release + * un-cancels); dragging all the way cancels mid-gesture; [onSlideProgress] + * reports the 1 → 0 drag fraction so the recording strip can track and fade + * its hint; locking only arms while the gesture stays clear of the cancel zone + * ([LOCK_MIN_ALPHA]). * * The whole down-to-up sequence lives in this one pointer loop, so the release * that submits can never be lost to a recomposition between press and * finger-up — an earlier interaction-source based approach could lose it and * leave the recorder running until some later tap "sent" the stale clip. + * + * Once the hold phase arms, every event is consumed: the shared click handler + * otherwise also observes the stationary release that ends a hold (there is no + * drag to cancel it), and re-dispatches the tap action — the system dictation + * intent launched every time a voice note was sent (regression, #1247). */ private suspend fun PointerInputScope.micHoldHandler( isEnabled: () -> Boolean, onHoldStart: () -> Unit, onHoldEnd: () -> Unit, onHoldCancel: () -> Unit, + onHoldLock: () -> Unit, + onSlideProgress: (Float) -> Unit, + cancelDistance: Float, ) { - val cancelSlop = MIC_SLIDE_CANCEL_DP.dp.toPx() + val lockSlop = MIC_SLIDE_LOCK_DP.dp.toPx() awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) if (!isEnabled()) { return@awaitEachGesture } - fun slidAway(position: Offset): Boolean { - val dx = position.x - down.position.x - val dy = position.y - down.position.y - return dx < -cancelSlop || dy < -cancelSlop - } + fun slideAlpha(position: Offset): Float = + (1f + (position.x - down.position.x) / cancelDistance).coerceIn(0f, 1f) - // Phase 1 — a finger-up before the threshold is a plain tap. + fun slidUp(position: Offset): Boolean = position.y - down.position.y < -lockSlop + + // Phase 1 — a finger-up before the threshold is a plain tap, left to + // the click handler. Dragging into the cancel zone aborts the gesture. + // Events are not consumed yet, so the tap path stays intact. var earlyUp = false var gestureAborted = false withTimeoutOrNull(HOLD_TO_RECORD_THRESHOLD_MS) { @@ -765,7 +824,7 @@ private suspend fun PointerInputScope.micHoldHandler( earlyUp = true return@withTimeoutOrNull } - if (change.isConsumed || slidAway(change.position)) { + if (change.isConsumed || slideAlpha(change.position) < CANCEL_RELEASE_ALPHA) { gestureAborted = true return@withTimeoutOrNull } @@ -779,23 +838,44 @@ private suspend fun PointerInputScope.micHoldHandler( } // Phase 2 — the threshold passed with the finger down: record until - // the finger lifts (send) or slides away (cancel). No timeout here. + // the finger lifts (send, unless dragged into the cancel zone), drags + // all the way left (cancel), or slides up (lock hands-free). onHoldStart() var send = false + var lock = false try { while (true) { val event = awaitPointerEvent() val change = event.changes.firstOrNull { it.id == down.id } ?: break + val alpha = slideAlpha(change.position) + onSlideProgress(alpha) if (!change.pressed) { - send = true + change.consume() + send = alpha >= CANCEL_RELEASE_ALPHA break } - if (change.isConsumed || slidAway(change.position)) { + if (change.isConsumed) { + change.consume() break } + if (alpha <= 0f) { + change.consume() + break + } + if (slidUp(change.position) && alpha >= LOCK_MIN_ALPHA) { + lock = true + change.consume() + break + } + change.consume() } } finally { - if (send) onHoldEnd() else onHoldCancel() + onSlideProgress(1f) + when { + lock -> onHoldLock() + send -> onHoldEnd() + else -> onHoldCancel() + } } } } @@ -803,5 +883,14 @@ private suspend fun PointerInputScope.micHoldHandler( /** A press shorter than this is a tap; longer arms voice-note recording. */ private const val HOLD_TO_RECORD_THRESHOLD_MS = 400L -/** Sliding this far from the mic button (left or up) cancels the recording. */ -private const val MIC_SLIDE_CANCEL_DP = 64 +/** Sliding this far up from the mic button locks the recording hands-free. */ +private const val MIC_SLIDE_LOCK_DP = 57 + +/** Full slide-to-cancel distance: a third of the composer (Telegram). */ +private const val MAX_CANCEL_DISTANCE_DP = 140 + +/** Releasing below this drag fraction of the cancel distance cancels. */ +private const val CANCEL_RELEASE_ALPHA = 0.45f + +/** Locking only arms while the gesture stays clear of the cancel zone. */ +private const val LOCK_MIN_ALPHA = 0.7f diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionCatalogDetails.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionCatalogDetails.kt new file mode 100644 index 00000000..64a7a92c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionCatalogDetails.kt @@ -0,0 +1,122 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.CatalogScanStatus +import com.m57.hermescontrol.data.model.ConnectionCatalogInfo +import com.m57.hermescontrol.data.model.ConnectionTargetKind +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +private const val SHORT_SHA_LENGTH = 12 + +/** What a plugin/skill install row would bring in: source, pin, scan and requirements (#1287). */ +@Composable +internal fun ConnectionCatalogDetails( + kind: ConnectionTargetKind, + catalog: ConnectionCatalogInfo, + modifier: Modifier = Modifier, +) { + val muted = MaterialTheme.colorScheme.onSurfaceVariant + Column( + modifier = modifier.testTag("connection_catalog_details"), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val kindLabel = + stringResource( + if (kind == ConnectionTargetKind.SKILL) { + R.string.connection_catalog_kind_skill + } else { + R.string.connection_catalog_kind_plugin + }, + ) + val tierLabel = + when (catalog.tier) { + null -> null + "official" -> stringResource(R.string.connection_catalog_tier_official) + "community" -> stringResource(R.string.connection_catalog_tier_community) + else -> catalog.tier + } + Text( + text = listOfNotNull(kindLabel, tierLabel).joinToString(" · "), + style = MaterialTheme.typography.labelLarge, + color = muted, + ) + catalog.description?.let { Text(it) } + catalog.repo?.let { repo -> + val source = catalog.subdir?.let { "$repo/$it" } ?: repo + Text( + text = stringResource(R.string.connection_catalog_source, source), + color = muted, + modifier = Modifier.testTag("connection_catalog_source"), + ) + } + catalog.sha?.let { sha -> + Text( + text = stringResource(R.string.connection_catalog_pin, sha.take(SHORT_SHA_LENGTH)), + color = muted, + modifier = Modifier.testTag("connection_catalog_pin"), + ) + } + if (catalog.platforms.isNotEmpty()) { + Text( + text = stringResource(R.string.connection_catalog_platforms, catalog.platforms.joinToString(", ")), + color = muted, + ) + } + CatalogScanLine(catalog) + if (catalog.requirements.isNotEmpty()) { + Text( + text = + stringResource( + R.string.connection_catalog_requirements, + catalog.requirements.joinToString(", "), + ), + color = muted, + modifier = Modifier.testTag("connection_catalog_requirements"), + ) + } + Text( + text = stringResource(R.string.connection_catalog_target_profile, catalog.targetProfile), + color = muted, + modifier = Modifier.testTag("connection_catalog_profile"), + ) + } +} + +@Composable +private fun CatalogScanLine(catalog: ConnectionCatalogInfo) { + val status = LocalHermesStatusColors.current + val scan = catalog.scan + val (label, color) = + when (scan?.status) { + CatalogScanStatus.PASSED -> { + R.string.connection_catalog_scan_passed to status.success + } + + CatalogScanStatus.WARNINGS -> { + R.string.connection_catalog_scan_warnings to status.warning + } + + CatalogScanStatus.FAILED -> { + R.string.connection_catalog_scan_failed to status.error + } + + CatalogScanStatus.UNKNOWN, null -> { + R.string.connection_catalog_scan_unreported to MaterialTheme.colorScheme.onSurfaceVariant + } + } + val summary = scan?.summary + Text( + text = stringResource(label) + if (summary != null) " · $summary" else "", + color = color, + modifier = Modifier.testTag("connection_catalog_scan"), + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt index 63b3b8e5..31ed7619 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.ConnectionOperationTarget import com.m57.hermescontrol.data.model.ConnectionTargetState +import com.m57.hermescontrol.ui.chat.ChatConnectionOperationDelegate import com.m57.hermescontrol.ui.chat.ConnectionOperationUiState @OptIn(ExperimentalMaterial3Api::class) @@ -101,8 +102,14 @@ internal fun ConnectionSetupContent( .padding(horizontal = 20.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { + val catalog = target.catalog Text( - text = stringResource(R.string.connection_setup_target_title, target.name), + text = + if (catalog != null) { + stringResource(R.string.connection_catalog_title, catalog.display) + } else { + stringResource(R.string.connection_setup_target_title, target.name) + }, style = MaterialTheme.typography.titleLarge, modifier = Modifier.testTag("connection_setup_title"), ) @@ -119,6 +126,7 @@ internal fun ConnectionSetupContent( target.instructions?.takeIf { it.isNotBlank() }?.let { instructions -> Text(instructions, color = MaterialTheme.colorScheme.onSurfaceVariant) } + catalog?.let { ConnectionCatalogDetails(kind = target.kind, catalog = it) } when { target.state == ConnectionTargetState.CONNECTED && !target.discoveryError.isNullOrBlank() -> { @@ -140,6 +148,12 @@ internal fun ConnectionSetupContent( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + catalog?.skill?.let { skill -> + Text( + text = stringResource(R.string.connection_catalog_skill_ready, skill), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } target.detail?.takeIf { it.isNotBlank() }?.let { detail -> Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -166,6 +180,21 @@ internal fun ConnectionSetupContent( } } + catalog != null && target.state == ConnectionTargetState.INITIATED -> { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.testTag("connection_catalog_installing"), + ) { + CircularProgressIndicator() + Text( + text = + target.detail?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.connection_catalog_installing), + ) + } + } + target.state == ConnectionTargetState.UNKNOWN || (target.state == ConnectionTargetState.INITIATED && target.connectUrl != null) -> { Text( @@ -219,14 +248,37 @@ internal fun ConnectionSetupContent( .fillMaxWidth() .testTag("connection_setup_connect"), ) { - Text(stringResource(R.string.connection_setup_connect)) + Text( + stringResource( + when { + catalog == null -> R.string.connection_setup_connect + target.state == ConnectionTargetState.FAILED -> R.string.connection_catalog_retry + else -> R.string.connection_catalog_install + }, + ), + ) } } } - state.error?.let { + state.error?.let { error -> Text( - text = stringResource(R.string.connection_setup_request_failed), + text = + stringResource( + when (error.message) { + ChatConnectionOperationDelegate.NOT_OWNER -> { + R.string.session_integrations_err_not_owner + } + + ChatConnectionOperationDelegate.UNSUPPORTED_RUNTIME -> { + R.string.session_integrations_err_unsupported_runtime + } + + else -> { + R.string.connection_setup_request_failed + } + }, + ), color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("connection_setup_error"), ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt deleted file mode 100644 index 9147e2fe..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.m57.hermescontrol.ui.chat.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.m57.hermescontrol.R -import com.m57.hermescontrol.ui.chat.PendingSend -import com.m57.hermescontrol.ui.chat.PendingSendState - -@Composable -fun PendingSendPanel( - sends: List, - mainTurnBusy: Boolean, - onSendNow: (String) -> Unit, -) { - // Delivery receipts stay internal; only queued work and recovery need UI. - val visibleSends = sends.filter { it.state != PendingSendState.SENDING && it.state != PendingSendState.ACCEPTED } - if (visibleSends.isEmpty()) return - Surface( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Column(modifier = Modifier.padding(8.dp)) { - Text(stringResource(R.string.chat_pending_sends), style = MaterialTheme.typography.titleSmall) - if (mainTurnBusy || visibleSends.any { it.state == PendingSendState.UNKNOWN }) { - Text( - stringResource(R.string.chat_pending_send_now_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - LazyColumn(modifier = Modifier.heightIn(max = 170.dp)) { - items(visibleSends, key = { it.id }) { send -> - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Column(modifier = Modifier.weight(1f)) { - Text( - send.text.ifBlank { stringResource(R.string.chat_pending_attachments) }, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = - when (send.state) { - PendingSendState.QUEUED -> stringResource(R.string.chat_pending_queued) - PendingSendState.PARKED -> stringResource(R.string.chat_pending_parked) - PendingSendState.SENDING -> stringResource(R.string.chat_pending_sending) - PendingSendState.ACCEPTED -> stringResource(R.string.chat_pending_accepted) - PendingSendState.UNKNOWN -> stringResource(R.string.chat_pending_unknown) - PendingSendState.REJECTED -> stringResource(R.string.chat_pending_rejected) - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (send.state in - setOf( - PendingSendState.QUEUED, - PendingSendState.PARKED, - PendingSendState.UNKNOWN, - PendingSendState.REJECTED, - PendingSendState.ACCEPTED, - ) - ) { - TextButton(onClick = { onSendNow(send.id) }) { - Text( - stringResource( - if (mainTurnBusy) { - R.string.chat_busy_stop_and_send - } else if (send.state in - setOf( - PendingSendState.UNKNOWN, - PendingSendState.ACCEPTED, - ) - ) { - R.string.chat_pending_send_again - } else { - R.string.chat_pending_send_now - }, - ), - ) - } - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/RememberChatSpeech.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/RememberChatSpeech.kt new file mode 100644 index 00000000..9ce3769d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/RememberChatSpeech.kt @@ -0,0 +1,99 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.session.ActiveSessionHolder + +/** + * Creates and remembers a screen/list-level [SpeechController] scoped to the active + * chat session, server endpoint, profile, and auth state. + * + * Handles lifecycle events: + * - Resets (stops playback and cleans cache) on [Lifecycle.Event.ON_STOP] + * - Resets on composition disposal or when the server endpoint, profile, auth state, or session changes + */ +@Composable +fun rememberChatSpeech( + synthesizer: SpeechSynthesizer? = null, + player: SpeechPlayer? = null, + onError: (() -> Unit)? = null, +): SpeechController { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val lifecycleOwner = LocalLifecycleOwner.current + val errorSynthesisMessage = stringResource(R.string.tts_error_synthesis) + + val dataScope by AuthManager.dataScopeFlow.collectAsStateWithLifecycle() + val activeSessionId by ActiveSessionHolder.activeSessionId.collectAsStateWithLifecycle() + + val profile = dataScope?.activeProfileId + val scopeKey = dataScope?.inMemoryKey(localKey = activeSessionId ?: "none").orEmpty() + + val resolvedSynthesizer = + remember(synthesizer, dataScope) { + synthesizer ?: AndroidSpeechSynthesizer( + context = context, + api = ApiClient.hermesApi, + profile = profile, + ) + } + + val resolvedPlayer = + remember(player, context) { + player ?: AndroidSpeechPlayer(context) + } + + val controller = + remember(scope, resolvedSynthesizer, resolvedPlayer, errorSynthesisMessage) { + SpeechController( + scope = scope, + synthesizer = resolvedSynthesizer, + player = resolvedPlayer, + onError = { + onError?.invoke() ?: Toast + .makeText( + context, + errorSynthesisMessage, + Toast.LENGTH_SHORT, + ).show() + }, + ) + } + + // Reset when session scope changes (session/profile/server/auth switch) + DisposableEffect(controller, scopeKey) { + onDispose { + controller.reset() + } + } + + // Reset on ON_STOP lifecycle event + DisposableEffect(lifecycleOwner, controller) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) { + controller.reset() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + controller.reset() + } + } + + return controller +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechController.kt new file mode 100644 index 00000000..88f7958c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechController.kt @@ -0,0 +1,178 @@ +package com.m57.hermescontrol.ui.chat.components + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class SpeechRequest( + val scopeKey: String, + val messageId: String, + val text: String, +) + +interface SpeechAudio { + fun dispose() +} + +interface SpeechSynthesizer { + suspend fun synthesize(request: SpeechRequest): SpeechAudio +} + +interface SpeechPlayer { + fun play( + audio: SpeechAudio, + onEnded: () -> Unit, + onError: () -> Unit, + ) + + fun stop() +} + +class SpeechController( + private val scope: CoroutineScope, + private val synthesizer: SpeechSynthesizer, + private val player: SpeechPlayer, + private val onError: () -> Unit, +) { + private val _speakingId = MutableStateFlow(null) + val speakingId: StateFlow = _speakingId.asStateFlow() + + private var activeRequest: SpeechRequest? = null + private var activeJob: Job? = null + private var generation: Long = 0L + + private var cachedRequest: SpeechRequest? = null + private var cachedAudio: SpeechAudio? = null + + fun toggle(request: SpeechRequest) { + if (request.text.isBlank()) { + return + } + + if (activeRequest == request) { + stop() + return + } + + start(request) + } + + fun stop() { + generation++ + activeJob?.cancel() + activeJob = null + activeRequest = null + player.stop() + _speakingId.value = null + } + + fun reset() { + stop() + disposeCache() + } + + private fun start(request: SpeechRequest) { + generation++ + val currentGen = generation + + activeJob?.cancel() + player.stop() + + if (cachedRequest != null && cachedRequest != request) { + disposeCache() + } + + activeRequest = request + _speakingId.value = request.messageId + + val cached = cachedAudio + if (cached != null && cachedRequest == request) { + playAudio(cached, currentGen) + return + } + + activeJob = + scope.launch { + var synthesizedAudio: SpeechAudio? = null + try { + val audio = synthesizer.synthesize(request) + synthesizedAudio = audio + + if (generation != currentGen) { + audio.dispose() + return@launch + } + + cachedAudio?.dispose() + cachedRequest = request + cachedAudio = audio + synthesizedAudio = null + + playAudio(audio, currentGen) + } catch (e: CancellationException) { + synthesizedAudio?.dispose() + throw e + } catch (e: Exception) { + synthesizedAudio?.dispose() + if (generation == currentGen) { + handlePlaybackFailure() + } + } + } + } + + private fun playAudio( + audio: SpeechAudio, + gen: Long, + ) { + try { + var completed = false + player.play( + audio = audio, + onEnded = { + if (generation == gen && !completed) { + completed = true + handlePlaybackFinished() + } + }, + onError = { + if (generation == gen && !completed) { + completed = true + handlePlaybackFailure() + } + }, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (generation == gen) { + handlePlaybackFailure() + } + } + } + + private fun handlePlaybackFinished() { + activeJob = null + activeRequest = null + player.stop() + _speakingId.value = null + } + + private fun handlePlaybackFailure() { + activeJob = null + activeRequest = null + player.stop() + _speakingId.value = null + onError() + } + + private fun disposeCache() { + cachedAudio?.dispose() + cachedAudio = null + cachedRequest = null + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechText.kt new file mode 100644 index 00000000..ad0668b9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SpeechText.kt @@ -0,0 +1,118 @@ +package com.m57.hermescontrol.ui.chat.components + +/** + * Plain-text preparation for per-message read-aloud (TTS speak button). + * + * Pure Kotlin — no Android imports — so it stays unit-testable on the JVM. + * Strips Markdown formatting while preserving readable prose, inline code, + * fenced code contents, link titles/alt texts, and Unicode/emojis. + */ +object SpeechText { + // Fenced code blocks: ``` or ~~~ with optional info string, spanning to closing fence or EOF. + private val FENCED_CODE_BLOCK_REGEX = + Regex("(?m)^[ \\t]*(`{3,}|~{3,})[^\\r\\n`~]*\\r?\\n([\\s\\S]*?)(?:^[ \\t]*\\1[ \\t]*(?:\\r?\\n|\\Z)|\\Z)") + + // Placeholders with null byte to guarantee safety from standard markdown text + private const val CODE_SPAN_PREFIX = "\u0000HERMESCODE" + private const val CODE_SPAN_SUFFIX = "\u0000" + + // Inline code: `code` or multiple backticks ``code`` + private val INLINE_CODE_REGEX = Regex("(`+)(.+?)\\1") + + // Markdown structures + private val IMAGE_LINK_REGEX = Regex("!\\[([^\\]]*)\\]\\([^)]*\\)") + private val LINK_REGEX = Regex("\\[([^\\]]*)\\]\\([^)]*\\)") + private val URL_REGEX = Regex("https?://\\S+") + private val HTML_TAG_REGEX = Regex("<[^>]+>") + + // Block elements (multiline) + private val HEADING_REGEX = Regex("(?m)^\\s{0,3}#{1,6}\\s+") + private val BLOCKQUOTE_REGEX = Regex("(?m)^\\s*>\\s?") + private val LIST_MARKER_REGEX = Regex("(?m)^\\s*(?:[-*+]|\\d+[.)])\\s+") + private val HORIZONTAL_RULE_REGEX = Regex("(?m)^\\s*([-_*])(?:\\s*\\1){2,}\\s*$") + + // Emphasis / strikethrough markers + private val BOLD_REGEX = Regex("(\\*\\*|__)(.*?)\\1") + private val ITALIC_REGEX = Regex("(\\*|_)(.*?)\\1") + private val STRIKETHROUGH_REGEX = Regex("~~(.*?)~~") + + // Punctuation set including Arabic question mark (\u061F) and semicolon (\u061B), and standard ones + // Punctuations that can end a sentence or pause: . , ! ? ; : ؟ ؛ + private const val PUNCTUATION_CLASS = "[.,!?;:\\u061F\\u061B]" + private val PUNCT_BEFORE_NEWLINE_REGEX = Regex("($PUNCTUATION_CLASS)[^\\S\\r\\n]*(?:\\r?\\n[^\\S\\r\\n]*)+") + private val PLAIN_NEWLINE_REGEX = Regex("[^\\S\\r\\n]*(?:\\r?\\n[^\\S\\r\\n]*)+") + private val WHITESPACE_REGEX = Regex("\\s+") + private val SPACE_BEFORE_PUNCT_REGEX = Regex("\\s+($PUNCTUATION_CLASS)") + + fun stripMarkdownForSpeech(text: String): String { + if (text.isBlank()) return "" + + val codeSpans = mutableListOf() + + // 1. Protect fenced code blocks before prose processing. + // Delimiters and info string are stripped; code content whitespace is normalized for speech. + var out = + FENCED_CODE_BLOCK_REGEX.replace(text) { matchResult -> + val rawContent = matchResult.groupValues[2] + val normalizedContent = + rawContent + .trim() + .split( + WHITESPACE_REGEX, + ).filter { it.isNotEmpty() } + .joinToString(" ") + val index = codeSpans.size + codeSpans.add(normalizedContent) + "\n$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX\n" + } + + // 2. Protect inline code spans before prose processing. + out = + INLINE_CODE_REGEX.replace(out) { matchResult -> + val content = matchResult.groupValues[2].trim() + val index = codeSpans.size + codeSpans.add(content) + "$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX" + } + + // 3. Process prose markdown elements + // Images: keep alt text, drop the URL. + out = out.replace(IMAGE_LINK_REGEX, "$1") + // Links: keep visible text, drop the target. + out = out.replace(LINK_REGEX, "$1") + // Autolinks / bare URLs: drop the URL, keep surrounding prose. + out = out.replace(URL_REGEX, " ") + // HTML tags. + out = out.replace(HTML_TAG_REGEX, " ") + // Headings, blockquotes, list markers, horizontal rules. + out = out.replace(HEADING_REGEX, "") + out = out.replace(BLOCKQUOTE_REGEX, "") + out = out.replace(LIST_MARKER_REGEX, "") + out = out.replace(HORIZONTAL_RULE_REGEX, " ") + // Table pipes + out = out.replace("|", " ") + // Emphasis / strikethrough markers + out = out.replace(BOLD_REGEX, "$2") + out = out.replace(ITALIC_REGEX, "$2") + out = out.replace(STRIKETHROUGH_REGEX, "$1") + + // 4. Normalise newlines into sensible pauses without accidental global punctuation mutation. + // If line ends with punctuation, keep it with a trailing space. + // Otherwise, replace newline with ". " to create a pause. + out = out.replace(PUNCT_BEFORE_NEWLINE_REGEX, "$1 ") + out = out.replace(PLAIN_NEWLINE_REGEX, ". ") + + // 5. Restore protected code spans (both fenced and inline) + for ((index, code) in codeSpans.withIndex()) { + out = out.replace("$CODE_SPAN_PREFIX$index$CODE_SPAN_SUFFIX", code) + } + + // 6. Collapse multiple whitespaces and trim + out = out.replace(WHITESPACE_REGEX, " ").trim() + + // 7. Tidy spaces before punctuation left behind by marker removal. + out = out.replace(SPACE_BEFORE_PUNCT_REGEX, "$1") + + return out + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt index 1cca004a..a214b443 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt @@ -1,51 +1,79 @@ package com.m57.hermescontrol.ui.chat.components import android.os.SystemClock +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import kotlinx.coroutines.delay +import kotlin.math.roundToInt /** * Telegram-style recording strip shown in place of the input field while a - * voice note is being recorded: a slide-to-cancel hint, an elapsed timer, and - * a live mic level meter so the press and the captured audio are both visible - * even when the mic button itself is under the thumb. + * voice note is being recorded. Layout mirrors Telegram's recorder: a blinking + * red dot and the elapsed timer sit on the left; "◀ Slide to cancel" (with the + * word "cancel" emphasized) sits centered to their right, gently nudging back + * and forth, and slides left and fades as the gesture drags toward cancel + * ([slideProgress] runs 1 → 0 across that drag). Once the gesture slides up to + * lock, the hint is replaced by a bold tappable "CANCEL" and the action button + * submits the note. */ @Composable internal fun VoiceNoteRecordingPanel( - amplitude: State, + slideProgress: State, modifier: Modifier = Modifier, + locked: Boolean = false, + onCancel: () -> Unit = {}, ) { val palette = composerPalette() + val density = LocalDensity.current + val haptic = LocalHapticFeedback.current var elapsedSeconds by remember { mutableStateOf(0L) } LaunchedEffect(Unit) { val startedAt = SystemClock.elapsedRealtime() @@ -54,71 +82,130 @@ internal fun VoiceNoteRecordingPanel( delay(200) } } - val levels = remember { mutableStateListOf() } - LaunchedEffect(Unit) { - snapshotFlow { amplitude.value }.collect { level -> - levels.add(level) - while (levels.size > VOICE_NOTE_BARS) { - levels.removeAt(0) - } - } - } + // Telegram blinks the record dot on a 1200 ms cycle. + val blink = rememberInfiniteTransition(label = "voice_dot") + val dotAlpha by + blink.animateFloat( + initialValue = 1f, + targetValue = 0.35f, + animationSpec = infiniteRepeatable(tween(600, easing = LinearEasing), RepeatMode.Reverse), + label = "voice_dot_alpha", + ) + // ...and nudges the cancel hint ±6 dp while the gesture is idle (Telegram + // moves it 3 dp / 250 ms), so the slide affordance announces itself. + val nudgeDp by + blink.animateFloat( + initialValue = -6f, + targetValue = 6f, + animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing), RepeatMode.Reverse), + label = "voice_hint_nudge", + ) + val progress = slideProgress.value.coerceIn(0f, 1f) Row( modifier = modifier .heightIn(min = 42.dp) .testTag("voice_note_recording_panel"), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - // Decorative: the adjacent "Slide to cancel" text carries the - // instruction, so the arrow must not repeat it (review, PR #1250). - contentDescription = null, - tint = palette.placeholder, - modifier = Modifier.size(16.dp), - ) - Text( - text = stringResource(R.string.chat_voice_slide_to_cancel), - style = MaterialTheme.typography.labelMedium, - color = palette.placeholder, - maxLines = 1, - ) - Spacer(modifier = Modifier.weight(1f)) Box( - modifier = - Modifier - .size(8.dp) - .background(MaterialTheme.colorScheme.error, CircleShape), - ) + modifier = Modifier.size(28.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .size(12.dp) + .background(MaterialTheme.colorScheme.error.copy(alpha = dotAlpha), CircleShape), + ) + } + Spacer(modifier = Modifier.width(6.dp)) Text( text = formatElapsed(elapsedSeconds), style = MaterialTheme.typography.labelLarge, - color = palette.text, + fontWeight = FontWeight.Bold, + color = palette.placeholder, ) - Row( - modifier = Modifier.height(24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), + BoxWithConstraints( + modifier = + Modifier + .weight(1f) + .clipToBounds(), + contentAlignment = Alignment.Center, ) { - val filler = VOICE_NOTE_BARS - levels.size - repeat(VOICE_NOTE_BARS) { index -> - val level = if (index < filler) 0f else levels[index - filler] - Box( + val containerWidthPx = with(density) { maxWidth.toPx() } + if (locked) { + Text( + text = stringResource(R.string.chat_voice_cancel).uppercase(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = palette.text, modifier = Modifier - .width(3.dp) - .height((4f + 18f * level).dp) - .background(palette.placeholder, RoundedCornerShape(2.dp)), + .minimumInteractiveComponentSize() + .clickable(role = Role.Button) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onCancel() + }.testTag("voice_note_cancel_button"), ) + } else { + Row( + modifier = + Modifier + .offset { + val shift = -containerWidthPx * 0.25f * (1f - progress) + val nudge = nudgeDp * density.density * progress + IntOffset((shift + nudge).roundToInt(), 0) + }.alpha(progress), + verticalAlignment = Alignment.CenterVertically, + ) { + CancelChevron(tint = palette.placeholder) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = slideToCancelText(palette), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + ) + } } } } } -/** Number of level-meter bars in the recording panel. */ -private const val VOICE_NOTE_BARS = 14 +/** "Slide to cancel" with the cancel word carrying the visual emphasis. */ +@Composable +private fun slideToCancelText(palette: ComposerPalette) = + buildAnnotatedString { + // Two resources so translators control both parts independently; the + // space between them is added here (review, PR #1280). + withStyle(SpanStyle(color = palette.placeholder)) { + append(stringResource(R.string.chat_voice_slide_to_cancel_prefix)) + append(" ") + } + withStyle(SpanStyle(color = palette.text, fontWeight = FontWeight.Bold)) { + append(stringResource(R.string.chat_voice_slide_to_cancel_action)) + } + } + +/** The small drawn left chevron that precedes the cancel hint. */ +@Composable +private fun CancelChevron(tint: Color) { + Canvas(modifier = Modifier.size(width = 4.dp, height = 10.dp)) { + val stroke = 1.6.dp.toPx() + val midY = size.height / 2f + val path = + Path().apply { + moveTo(size.width - stroke / 2f, midY - 5.dp.toPx()) + lineTo(stroke / 2f, midY) + lineTo(size.width - stroke / 2f, midY + 5.dp.toPx()) + } + drawPath( + path = path, + color = tint, + style = Stroke(width = stroke, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) + } +} /** Formats elapsed recording time as m:ss. */ private fun formatElapsed(seconds: Long): String { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 604d7fae..8899c7d4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -13,8 +13,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -65,6 +67,8 @@ internal fun FullBleedAgentMessage( openingAttachmentPath: String? = null, canSaveAttachment: Boolean = true, onImageClick: (ImageViewerModel) -> Unit = {}, + isSpeaking: Boolean = false, + onToggleSpeak: (() -> Unit)? = null, messageStatsEnabled: Boolean = false, showAssistantMessageTokens: Boolean = true, showTokensPerSecond: Boolean = true, @@ -162,6 +166,31 @@ internal fun FullBleedAgentMessage( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } + if (onToggleSpeak != null) { + IconButton( + onClick = onToggleSpeak, + modifier = Modifier.size(28.dp).testTag("fullbleed_speak"), + ) { + Icon( + imageVector = + if (isSpeaking) { + Icons.Filled.Stop + } else { + Icons.AutoMirrored.Filled.VolumeUp + }, + contentDescription = + stringResource( + if (isSpeaking) { + R.string.content_desc_stop_speaking + } else { + R.string.content_desc_speak + }, + ), + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } if (showTokenStat) { AssistantStatItem( value = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index 431b0195..0deaff27 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -3,11 +3,15 @@ package com.m57.hermescontrol.ui.chat.fullbleed import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -105,6 +109,10 @@ fun FullBleedChatList( pagingSessionId: String? = null, onLoadOlder: () -> Unit = viewModel::loadOlderMessages, replyErrorContent: (@Composable () -> Unit)? = null, + speakingMessageId: String? = null, + onToggleSpeak: ((ChatMessage) -> Unit)? = null, + isCompressing: Boolean = false, + compressionStatus: String? = null, ) { if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) { Box( @@ -147,6 +155,23 @@ fun FullBleedChatList( val tailItems = buildMap Unit> { replyErrorContent?.let { put("reply_error", it) } + if (isCompressing || compressionStatus != null) { + put("compression_status") { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = compressionStatus ?: "⏳ Compressing context...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } agentStatus?.let { status -> put("agent_status") { AgentStatusIndicator(status = status) @@ -363,6 +388,15 @@ fun FullBleedChatList( showReasoning = !hoistedReasoning, ) } else { + val isSpeakingThis = + speakingMessageId != null && + speakingMessageId == proseMessage.id + val toggleSpeakAction = + if (onToggleSpeak != null) { + { onToggleSpeak(proseMessage) } + } else { + null + } FullBleedAgentMessage( message = proseMessage, // Highlight only bubbles that actually contain a match — @@ -385,6 +419,8 @@ fun FullBleedChatList( openingAttachmentPath = openingAttachmentPath, canSaveAttachment = savingAttachmentPath == null, onImageClick = onImageClick, + isSpeaking = isSpeakingThis, + onToggleSpeak = toggleSpeakAction, messageStatsEnabled = messageStatsEnabled, showAssistantMessageTokens = showAssistantMessageTokens, showTokensPerSecond = showTokensPerSecond, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt index aed5a017..430e777b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt @@ -64,4 +64,5 @@ data class ToolView( val searchQuery: String? = null, /** Detected error text (destructive styling in the view). */ val error: String? = null, + val serverDisplayName: String? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index 8666af00..f1807d13 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -1,7 +1,10 @@ package com.m57.hermescontrol.ui.chat.tool +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull /** * The tool-display engine's orchestrator — a port of the desktop app's @@ -48,12 +51,14 @@ object ToolViewBuilder { ToolStatusResolver.errorText(call, isError) } + val serverTitle = serverLabelTitle(call.args) val title = - when { - running -> renderer.pendingTitle(call) - error.isNotEmpty() -> renderer.errorTitle(call) - else -> renderer.doneTitle(call) - } ?: ToolJson.titleForTool(toolName) + serverTitle + ?: when { + running -> renderer.pendingTitle(call) + error.isNotEmpty() -> renderer.errorTitle(call) + else -> renderer.doneTitle(call) + } ?: ToolJson.titleForTool(toolName) val subtitle = if (error.isNotEmpty()) { @@ -103,6 +108,7 @@ object ToolViewBuilder { return ToolView( status = status, title = title, + serverDisplayName = serverTitle, subtitle = subtitle, detail = clampedDetail, detailLabel = extras.detailLabel, @@ -125,6 +131,20 @@ object ToolViewBuilder { ) } + /** #1288: server display metadata, not the real tool's `labels` argument. */ + internal fun serverLabelTitle(args: JsonObject?): String? = + (args?.get("hermes_tool_labels") as? JsonArray) + ?.mapNotNull { entry -> + val row = entry as? JsonObject ?: return@mapNotNull null + val text = + (row["text"] as? JsonPrimitive)?.contentOrNull?.takeIf(String::isNotBlank) + ?: return@mapNotNull null + val app = (row["app"] as? JsonPrimitive)?.contentOrNull.orEmpty() + if (app.isNotBlank() && !text.contains(app, ignoreCase = true)) "$app · $text" else text + }?.distinct() + ?.joinToString(" · ") + ?.takeIf(String::isNotBlank) + /** Subtitle fallback: first line of the heuristic summary, then contexts. */ private fun genericSubtitle(call: ToolCall): String { val summary = ToolResultSummary.formatToolResultSummary(call.rawResult) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt index ae8095df..8a534ee0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt @@ -58,6 +58,18 @@ internal object FileEditSupport { } } + // Support persisted edit previews from display_metadata.tool_result_metadata.inline_diff or display_metadata.inline_diff + val displayMetadata = + record["display_metadata"] as? JsonObject + ?: ToolJson.parseMaybeObject(record["display_metadata"]) + val toolResultMeta = + displayMetadata?.get("tool_result_metadata") as? JsonObject + ?: ToolJson.parseMaybeObject(displayMetadata?.get("tool_result_metadata")) + val metaDiff = toolResultMeta?.get("inline_diff") ?: displayMetadata?.get("inline_diff") + if (metaDiff is JsonPrimitive && metaDiff.isString && metaDiff.content.trim().isNotEmpty()) { + return stripInlineDiffChrome(metaDiff.content) + } + return "" } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/CustomHeadersButton.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/CustomHeadersButton.kt new file mode 100644 index 00000000..aabcf604 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/CustomHeadersButton.kt @@ -0,0 +1,196 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.remote.CustomHeaders +import com.m57.hermescontrol.data.remote.ServerEndpoint +import com.m57.hermescontrol.data.remote.ServerHeaders +import com.m57.hermescontrol.theme.Spacing +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import java.io.IOException + +@Composable +fun CustomHeadersButton( + baseUrl: String, + enabled: Boolean = true, + onSaved: () -> Unit, +) { + val endpoint = remember(baseUrl) { runCatching { ServerEndpoint.parseForBuild(baseUrl) }.getOrNull() } + var editing by remember(baseUrl) { mutableStateOf(false) } + OutlinedButton( + onClick = { editing = true }, + enabled = enabled && endpoint != null, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.custom_headers_title)) + } + if (editing && endpoint != null) { + CustomHeadersDialog( + baseUrl = endpoint.baseUrl, + onDismiss = { editing = false }, + onSaved = { + editing = false + onSaved() + }, + ) + } +} + +@Composable +private fun CustomHeadersDialog( + baseUrl: HttpUrl, + onDismiss: () -> Unit, + onSaved: () -> Unit, +) { + var rows by remember { mutableStateOf(ServerHeaders.get(baseUrl).entries.toList()) } + var visible by remember { mutableStateOf(false) } + var saving by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var deleting by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + + AlertDialog( + onDismissRequest = { if (!saving) onDismiss() }, + title = { Text(stringResource(R.string.custom_headers_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + Text(baseUrl.toString(), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.custom_headers_description)) + rows.forEachIndexed { index, (name, value) -> + OutlinedTextField( + value = name, + onValueChange = { new -> + rows = rows.mapIndexed { i, row -> if (i == index) new to row.second else row } + error = null + }, + label = { Text(stringResource(R.string.custom_headers_name)) }, + singleLine = true, + enabled = !saving, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = value, + onValueChange = { new -> + rows = rows.mapIndexed { i, row -> if (i == index) row.first to new else row } + error = null + }, + label = { Text(stringResource(R.string.custom_headers_value)) }, + singleLine = true, + enabled = !saving, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = + if (visible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { visible = !visible }) { + Icon( + imageVector = + if (visible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = + stringResource( + if (visible) R.string.custom_headers_hide else R.string.custom_headers_show, + ), + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + TextButton(onClick = { deleting = index }, enabled = !saving) { + Text(stringResource(R.string.custom_headers_remove)) + } + } + OutlinedButton(onClick = { rows = rows + ("" to "") }, enabled = !saving) { + Text(stringResource(R.string.custom_headers_add)) + } + if (rows.isEmpty()) { + TextButton( + onClick = { + rows = listOf("CF-Access-Client-Id" to "", "CF-Access-Client-Secret" to "") + }, + enabled = !saving, + ) { + Text(stringResource(R.string.custom_headers_cloudflare)) + } + } + error?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { + Button( + enabled = !saving, + onClick = { + val headers = runCatching { CustomHeaders.parse(rows) }.getOrNull() + if (headers == null) { + error = R.string.custom_headers_invalid + } else { + saving = true + coroutineScope.launch { + try { + withContext(Dispatchers.IO) { ServerHeaders.save(baseUrl, headers) } + onSaved() + } catch (_: IOException) { + error = R.string.custom_headers_save_failed + } finally { + saving = false + } + } + } + }, + ) { Text(stringResource(R.string.action_save)) } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !saving) { Text(stringResource(R.string.action_cancel)) } + }, + ) + deleting?.let { index -> + AlertDialog( + onDismissRequest = { deleting = null }, + title = { Text(stringResource(R.string.custom_headers_remove)) }, + text = { Text(stringResource(R.string.custom_headers_remove_confirm)) }, + confirmButton = { + TextButton(onClick = { + rows = rows.filterIndexed { i, _ -> i != index } + deleting = null + error = null + }) { Text(stringResource(R.string.action_delete)) } + }, + dismissButton = { + TextButton(onClick = { deleting = null }) { Text(stringResource(R.string.action_cancel)) } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt index c5763b3b..bd9195ad 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/DetailRows.kt @@ -131,7 +131,7 @@ fun WebhookSubscription.toDetailRows(): List = * Map a free-form status string to a semantic tone. Recognises the app's * common status vocabulary (enabled/active/running/ok → success, etc.). */ -private fun toneForStatus(status: String?): DetailRowTone { +fun toneForStatus(status: String?): DetailRowTone { if (status.isNullOrBlank()) return DetailRowTone.NONE return when (status.lowercase()) { "enabled", "active", "running", "ok", "online", "connected" -> DetailRowTone.SUCCESS diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsScreen.kt new file mode 100644 index 00000000..77352cf9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsScreen.kt @@ -0,0 +1,278 @@ +package com.m57.hermescontrol.ui.connectors + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.ExternalActivityLifecycleGuard +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.ConnectorAccount +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.notification.NotificationHelper +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.LoadingState +import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.util.ConnectorUrlValidator + +@Composable +fun AccountConnectorsScreen( + onOpenDrawer: () -> Unit, + vm: AccountConnectorsViewModel = viewModel { AccountConnectorsViewModel() }, +) { + val state by vm.state.collectAsStateWithLifecycle() + val connection by vm.connectionState.collectAsStateWithLifecycle() + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val spacing = LocalSpacing.current + var pendingRemoval by remember(state.scope) { mutableStateOf(null) } + var browserOperation by remember(state.scope) { mutableStateOf(null) } + var departed by remember(state.scope) { mutableStateOf(false) } + DisposableEffect(lifecycleOwner, state.scope) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE && browserOperation != null) departed = true + if (event == Lifecycle.Event.ON_RESUME && departed) { + val opId = browserOperation + browserOperation = null + departed = false + ExternalActivityLifecycleGuard.externalActivityReturned() + if (opId != null) vm.onBrowserReturn(opId) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + if (browserOperation != null) ExternalActivityLifecycleGuard.externalActivityReturned() + } + } + HermesScaffold( + title = { Text(stringResource(R.string.account_connectors_title)) }, + navigationIcon = NavIcon.Menu(onOpenDrawer), + onRefresh = vm::refresh, + ) { + when { + state.loading -> { + LoadingState() + } + + state.error != null && state.catalog.isEmpty() && state.accounts.isEmpty() -> { + ErrorState(message = state.error.orEmpty(), onRetry = vm::refresh) + } + + state.catalog.isEmpty() && state.accounts.isEmpty() -> { + EmptyState(title = stringResource(R.string.account_connectors_no_accounts)) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.md), + ) { + state.error?.let { error -> item { Text(error, color = MaterialTheme.colorScheme.error) } } + items(state.catalog, key = { "catalog-${it.slug}" }) { connector -> + val current = state.connectorStates.firstOrNull { it.connector == connector.slug } + val policy = state.policy + val enabled = policy?.connectorEnabled(connector.slug) ?: current?.enabled + val locked = policy?.inherited?.any { !it.connectorEnabled(connector.slug) } == true + val editable = policy?.member != null && !state.busy && !locked + Card(Modifier.fillMaxWidth()) { + Column( + Modifier.padding(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Text(connector.name, style = MaterialTheme.typography.titleMedium) + if (connector.description.isNotBlank()) Text(connector.description) + Text( + current?.connectionStatus + ?: stringResource(R.string.account_connectors_not_connected), + ) + // Stack actions so large fonts and narrow phones never hide a control. + Button( + onClick = { vm.connect(connector.slug, reconnect = current?.isConnected == true) }, + enabled = !state.busy && connection.operation == null, + ) { + Text( + stringResource( + if (current?.isConnected == + true + ) { + R.string.account_connectors_reconnect + } else { + R.string.account_connectors_connect + }, + ), + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = enabled == true, + enabled = editable, + onCheckedChange = { vm.setEnabled(connector.slug, it) }, + ) + Text(stringResource(R.string.account_connectors_enabled)) + } + if (locked || + policy?.member == null + ) { + Text(stringResource(R.string.account_connectors_policy_locked)) + } + OutlinedButton( + onClick = { vm.loadTools(connector.slug) }, + enabled = connector.slug !in state.toolsLoading, + ) { Text(stringResource(R.string.account_connectors_tools)) } + state.tools[connector.slug]?.let { tools -> + if (tools.isEmpty()) Text(stringResource(R.string.account_connectors_no_tools)) + tools.forEach { tool -> + val disabled = + tool.slug in + policy + ?.member + ?.tools + ?.get(connector.slug) + .orEmpty() + val toolLocked = + policy?.inherited?.any { !it.toolEnabled(connector.slug, tool) } == true + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = enabled == true && !disabled && !toolLocked, + enabled = editable && enabled == true && !toolLocked, + onCheckedChange = { checked -> + val updated = + state.policy + ?.member + ?.tools + ?.get( + connector.slug, + ).orEmpty() + .toMutableSet() + if (checked) updated.remove(tool.slug) else updated.add(tool.slug) + vm.setDisabledTools(connector.slug, updated.toList()) + }, + ) + Column(Modifier.weight(1f)) { + Text(tool.name) + if (toolLocked) { + Text( + stringResource(R.string.account_connectors_policy_locked), + ) + } + Text(tool.description, style = MaterialTheme.typography.bodySmall) + Text(tool.facet, style = MaterialTheme.typography.labelSmall) + } + } + } + } + } + } + } + item { + Text( + stringResource(R.string.account_connectors_accounts), + style = MaterialTheme.typography.titleMedium, + ) + } + items(state.accounts, key = { "account-${it.connectionId}" }) { account -> + Card(Modifier.fillMaxWidth()) { + Column( + Modifier.padding(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Text(account.label, style = MaterialTheme.typography.titleMedium) + Text( + state.catalog.firstOrNull { it.slug == account.connector }?.name + ?: account.connector, + ) + Text(account.status) + account.statusReason?.let { Text(it) } + account.alias?.let { Text(it) } + OutlinedButton(onClick = { pendingRemoval = account }, enabled = !state.busy) { + Text(stringResource(R.string.account_connectors_remove)) + } + } + } + } + if (state.accounts.isEmpty()) item { Text(stringResource(R.string.account_connectors_no_accounts)) } + } + } + } + } + ConnectionSetupSheet( + state = connection, + onRespond = vm::respond, + onContinue = vm::continueOperation, + onDismiss = vm::continueOperation, + onOpenBrowser = { opId, url -> + if (ConnectorUrlValidator.isValidHttpsUrl(url)) { + try { + ExternalActivityLifecycleGuard.launchExternalActivity( + acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, + releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease, + prepareForBackground = { NotificationHelper.start(context) }, + cleanupAfterLaunchFailure = { NotificationHelper.stop(context) }, + ) { + browserOperation = opId + departed = false + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + } catch (_: Exception) { + browserOperation = null + ExternalActivityLifecycleGuard.externalActivityReturned() + vm.browserLaunchFailed() + } + } + }, + ) + pendingRemoval?.let { account -> + AlertDialog( + onDismissRequest = { pendingRemoval = null }, + title = { Text(stringResource(R.string.account_connectors_remove_confirm_title)) }, + text = { Text(stringResource(R.string.account_connectors_remove_confirm_message, account.label)) }, + confirmButton = { + TextButton(onClick = { + vm.remove(account.connectionId) + pendingRemoval = null + }, enabled = !state.busy) { Text(stringResource(R.string.account_connectors_remove)) } + }, + dismissButton = { + TextButton( + onClick = { pendingRemoval = null }, + ) { Text(stringResource(R.string.common_cancel)) } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt new file mode 100644 index 00000000..aacfe702 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt @@ -0,0 +1,268 @@ +package com.m57.hermescontrol.ui.connectors + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope +import com.m57.hermescontrol.data.model.AccountConnectorResult +import com.m57.hermescontrol.data.model.ConnectorAccount +import com.m57.hermescontrol.data.model.ConnectorCatalogEntry +import com.m57.hermescontrol.data.model.ConnectorItem +import com.m57.hermescontrol.data.model.ConnectorPolicy +import com.m57.hermescontrol.data.model.ConnectorTool +import com.m57.hermescontrol.data.ws.AccountConnectorRepository +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.ui.chat.ChatConnectionOperationDelegate +import com.m57.hermescontrol.ui.chat.ConnectionOperationRequester +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class AccountConnectorsState( + val loading: Boolean = true, + val error: String? = null, + val catalog: List = emptyList(), + val connectorStates: List = emptyList(), + val accounts: List = emptyList(), + val tools: Map> = emptyMap(), + val toolsLoading: Set = emptySet(), + val policy: ConnectorPolicy? = null, + val busy: Boolean = false, + val scope: DataScope? = null, +) + +class AccountConnectorsViewModel( + private val repository: AccountConnectorRepository = AccountConnectorRepository, + private val scopes: StateFlow = AuthManager.dataScopeFlow, + private val currentScope: () -> DataScope? = AuthManager::currentDataScope, + events: Flow = HermesWsClient.events, +) : ViewModel() { + private val _state = MutableStateFlow(AccountConnectorsState()) + val state: StateFlow = _state.asStateFlow() + private var generation = 0L + private var loadJob: Job? = null + private val scopedJobs = mutableListOf() + private val operation = + ChatConnectionOperationDelegate( + requester = ConnectionOperationRequester(repository::operationRequest), + accountOwned = true, + ) + val connectionState = operation.state + + init { + viewModelScope.launch { + scopes.collect { scope -> + generation++ + loadJob?.cancel() + scopedJobs.forEach(Job::cancel) + scopedJobs.clear() + operation.reset() + _state.value = AccountConnectorsState(scope = scope) + if (scope != null) load() else _state.update { it.copy(loading = false) } + } + } + viewModelScope.launch { + events.collect { event -> + if (event is WsEvent.GatewayReady) refresh() + val snapshot = (event as? WsEvent.ConnectionUpdate)?.snapshot ?: return@collect + // Never adopt an unsolicited account event: only our acknowledged operation belongs here. + if (isCurrent(generation) && snapshot.accountOwned && + snapshot.opId == + operation.state.value.operation + ?.opId && operation.acceptUpdate(snapshot) + ) { + if (snapshot.settled) load() + } + } + } + } + + private fun isCurrent(epoch: Long): Boolean = epoch == generation && _state.value.scope == currentScope() + + fun refresh() { + if (!_state.value.busy) { + load() + if (connectionState.value.operation != null) operationAction { } + } + } + + private fun load() { + loadJob?.cancel() + val epoch = generation + _state.update { it.copy(loading = true, error = null) } + loadJob = + viewModelScope.launch { + val list = async { repository.listConnectors() } + val catalog = async { repository.catalog() } + val accounts = async { repository.accounts() } + val policy = async { repository.policy() } + val listResult = list.await() + val catalogResult = catalog.await() + val accountsResult = accounts.await() + val policyResult = policy.await() + if (!isCurrent(epoch)) return@launch + _state.update { + it.copy( + loading = false, + error = + listResult.errorOrNull()?.message ?: catalogResult.errorMessage() + ?: accountsResult.errorMessage() ?: policyResult.errorMessage(), + catalog = catalogResult.valueOrNull().orEmpty(), + connectorStates = listResult.getOrNull().orEmpty(), + accounts = accountsResult.valueOrNull().orEmpty(), + policy = policyResult.valueOrNull(), + ) + } + } + } + + fun loadTools(slug: String) { + scopedJobs.removeAll { it.isCompleted } + if (slug in _state.value.toolsLoading || !isCurrent(generation)) return + val epoch = generation + _state.update { it.copy(toolsLoading = it.toolsLoading + slug) } + scopedJobs += + viewModelScope.launch { + val result = repository.tools(slug) + if (!isCurrent(epoch)) return@launch + _state.update { + it.copy( + tools = result.valueOrNull()?.let { tools -> it.tools + (slug to tools) } ?: it.tools, + toolsLoading = it.toolsLoading - slug, + error = result.errorMessage(), + ) + } + } + } + + fun connect( + slug: String, + reconnect: Boolean = false, + ) = mutate { + val epoch = generation + val result = repository.connect(listOf(slug), reconnect) + if (isCurrent(epoch)) { + result.valueOrNull()?.let { snapshot -> + operation.acceptRequest(snapshot) + if (snapshot.settled) load() + } + } + result.errorMessage() + } + + fun remove(connectionId: String) = + mutate { + val epoch = generation + val result = repository.remove(connectionId) + if (isCurrent(epoch) && result is AccountConnectorResult.Success) load() + result.errorMessage() + } + + fun setEnabled( + slug: String, + enabled: Boolean, + ) = updatePolicy { + repository.setConnectorEnabled( + slug, + enabled, + _state.value.policy + ?.member + ?.revision + .orEmpty(), + ) + } + + fun setDisabledTools( + slug: String, + disabled: List, + ) = updatePolicy { + repository.setDisabledTools( + slug, + disabled, + _state.value.policy + ?.member + ?.revision + .orEmpty(), + ) + } + + private fun updatePolicy(action: suspend () -> AccountConnectorResult) = + mutate { + val epoch = generation + val result = action() + if (isCurrent(epoch)) result.valueOrNull()?.let { policy -> _state.update { it.copy(policy = policy) } } + result.errorMessage() + } + + // Reserve synchronously, not inside launch: repeated taps must not start concurrent writes. + private fun mutate(action: suspend () -> String?) { + scopedJobs.removeAll { it.isCompleted } + if (_state.value.scope == null || _state.value.busy || _state.value.loading || !isCurrent(generation)) return + val epoch = generation + _state.update { it.copy(busy = true, error = null) } + scopedJobs += + viewModelScope.launch { + try { + if (!isCurrent(epoch)) return@launch + val error = action() + if (isCurrent(epoch)) _state.update { it.copy(busy = false, error = error) } + } catch (e: CancellationException) { + throw e + } finally { + if (isCurrent(epoch)) _state.update { it.copy(busy = false) } + } + } + } + + fun respond( + target: String, + env: Map, + approved: Boolean, + ) = operationAction { + operation.respond(target, env, approved) + } + + fun continueOperation() = operationAction { operation.continueOperation() } + + fun onBrowserReturn(opId: String) = operationAction { operation.wake(opId) } + + private fun operationAction(action: suspend () -> Unit) { + scopedJobs.removeAll { it.isCompleted } + if (!isCurrent(generation)) return + val epoch = generation + val opId = connectionState.value.operation?.opId ?: return + scopedJobs += + viewModelScope.launch { + action() + if (!isCurrent(epoch)) return@launch + val result = repository.operationStatus(opId) + if (!isCurrent(epoch)) return@launch + when (result) { + is AccountConnectorResult.Success -> { + operation.acceptUpdate(result.value) + if (result.value.settled) load() + } + + is AccountConnectorResult.Failure -> { + _state.update { it.copy(error = result.error.message) } + } + } + } + } + + fun browserLaunchFailed() { + _state.update { it.copy(error = "Unable to open the authorization browser.") } + } +} + +private fun AccountConnectorResult.valueOrNull(): T? = (this as? AccountConnectorResult.Success)?.value + +private fun AccountConnectorResult<*>.errorMessage(): String? = + (this as? AccountConnectorResult.Failure)?.error?.message diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index ec585b4d..97ac71bf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -12,11 +12,13 @@ import com.m57.hermescontrol.data.model.McpServerTestResponse import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -66,8 +68,9 @@ data class McpServersUiState( val activeOAuthFlow: McpOAuthFlowResponse? = null, ) -class McpServersViewModel : - ViewModel(), +class McpServersViewModel( + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(McpServersUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -131,6 +134,7 @@ class McpServersViewModel : // ── Server toggle ──────────────────────────────────────── fun toggleServer(server: McpServer) { + if (server.isPluginOwned || isPluginOwned(server.name)) return val originalEnabled = server.enabled val targetEnabled = !originalEnabled @@ -145,7 +149,7 @@ class McpServersViewModel : viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.toggleMcpServer( server.name, @@ -163,7 +167,7 @@ class McpServersViewModel : } is NetworkResult.Failure -> { - revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}") + revertToggle(server.name, originalEnabled, mutationError(result.error, "Failed to toggle server")) } } } @@ -178,7 +182,7 @@ class McpServersViewModel : ) } val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.testMcpServer(name) } } when (result) { @@ -234,7 +238,7 @@ class McpServersViewModel : val testJobs = enabledServers.map { server -> - async(Dispatchers.IO) { + async(ioDispatcher) { val res = safeApiCall { ApiClient.hermesApi.testMcpServer(server.name) } server.name to res } @@ -271,9 +275,10 @@ class McpServersViewModel : } fun deleteServer(name: String) { + if (isPluginOwned(name)) return viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.deleteMcpServer(name) } } when (result) { @@ -283,7 +288,7 @@ class McpServersViewModel : } is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to delete server: ${result.error.message}") } + _uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to delete server")) } } } } @@ -311,7 +316,7 @@ class McpServersViewModel : viewModelScope.launch { val importJobs = requests.map { req -> - async(Dispatchers.IO) { + async(ioDispatcher) { val result = safeApiCall { ApiClient.hermesApi.addMcpServer(req) } req to result } @@ -387,7 +392,7 @@ class McpServersViewModel : viewModelScope.launch { val request = AddServerRequestBuilder.build(state) val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.addMcpServer(request) } } when (result) { @@ -412,7 +417,7 @@ class McpServersViewModel : _uiState.update { it.copy( addingServer = false, - toastMessage = "Failed to add server: ${result.error.message}", + toastMessage = mutationError(result.error, "Failed to add server"), ) } } @@ -423,6 +428,7 @@ class McpServersViewModel : // ── Env var editing ────────────────────────────────────── fun startEditingEnv(server: McpServer) { + if (server.isPluginOwned || isPluginOwned(server.name)) return _uiState.update { it.copy(editingEnvFor = server.name, envKeyInput = "", envValueInput = "") } } @@ -439,6 +445,7 @@ class McpServersViewModel : } fun addEnvVar(serverName: String) { + if (isPluginOwned(serverName)) return val state = _uiState.value val key = state.envKeyInput.trim() val value = state.envValueInput.trim() @@ -450,7 +457,7 @@ class McpServersViewModel : val existingEnv = state.servers.find { it.name == serverName }?.env ?: emptyMap() val updatedEnv = existingEnv + (key to value) val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) } } when (result) { @@ -466,7 +473,7 @@ class McpServersViewModel : } is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to add env var: ${result.error.message}") } + _uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to add env var")) } } } } @@ -476,12 +483,13 @@ class McpServersViewModel : serverName: String, key: String, ) { + if (isPluginOwned(serverName)) return viewModelScope.launch { val state = _uiState.value val existingEnv = state.servers.find { it.name == serverName }?.env ?: emptyMap() val updatedEnv = existingEnv - key val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) } } when (result) { @@ -491,7 +499,7 @@ class McpServersViewModel : } is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to remove env var: ${result.error.message}") } + _uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to remove env var")) } } } } @@ -503,7 +511,7 @@ class McpServersViewModel : _uiState.update { it.copy(catalogLoading = true, catalogError = null) } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getMcpCatalog() } } when (result) { @@ -542,7 +550,7 @@ class McpServersViewModel : env = state.catalogInstallEnv.ifEmpty { null }, ) val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.installMcpCatalogEntry(request) } } when (result) { @@ -584,11 +592,12 @@ class McpServersViewModel : server: McpServer, onOpenBrowser: (String) -> Unit, ) { + if (server.isPluginOwned || isPluginOwned(server.name)) return oauthPollJob?.cancel() _uiState.update { it.copy(toastMessage = "Starting OAuth authorization…") } viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.authMcpServer(server.name) } } when (result) { @@ -608,7 +617,7 @@ class McpServersViewModel : } is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to start OAuth: ${result.error.message}") } + _uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to start OAuth")) } } } } @@ -623,7 +632,7 @@ class McpServersViewModel : while (polling) { kotlinx.coroutines.delay(2000) val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getMcpOAuthFlowStatus(flowId) } } when (result) { @@ -688,6 +697,15 @@ class McpServersViewModel : // ── Helpers ────────────────────────────────────────────── + // #1283: re-check current ownership so stale row callbacks cannot mutate a plugin server. + private fun isPluginOwned(name: String): Boolean = + _uiState.value.servers.any { it.name == name && it.isPluginOwned } + + private fun mutationError( + error: NetworkError, + prefix: String, + ): String = if (error is NetworkError.Http && error.code == 409) error.message else "$prefix: ${error.message}" + private fun revertToggle( name: String, originalEnabled: Boolean, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt index 155f6c32..b5b22347 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt @@ -139,6 +139,16 @@ fun ServerCard( color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (server.isPluginOwned) { + Text( + text = + server.plugin?.let { stringResource(R.string.mcp_servers_from_plugin, it) } + ?: stringResource(R.string.mcp_servers_plugin_managed), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Tool count & schema token overhead badge (issue #1029) val toolInfos = testResult?.tools val toolCount = toolInfos?.size ?: server.tools?.size @@ -173,6 +183,7 @@ fun ServerCard( } Switch( checked = server.enabled, + enabled = !server.isPluginOwned, onCheckedChange = { viewModel.toggleServer(server) }, ) } @@ -218,6 +229,7 @@ fun ServerCard( if (server.auth == "oauth") { FilledTonalButton( onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) }, + enabled = !server.isPluginOwned, modifier = Modifier.weight(1.2f), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), ) { @@ -245,7 +257,10 @@ fun ServerCard( Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1) } } - IconButton(onClick = { viewModel.deleteServer(server.name) }) { + IconButton( + onClick = { viewModel.deleteServer(server.name) }, + enabled = !server.isPluginOwned, + ) { Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete)) } } @@ -275,7 +290,7 @@ fun EnvVarSection( spacing: Spacing, modifier: Modifier = Modifier, ) { - val isEditing = state.editingEnvFor == server.name + val isEditing = !server.isPluginOwned && state.editingEnvFor == server.name val env = server.env ?: emptyMap() Column(modifier = modifier) { @@ -300,7 +315,10 @@ fun EnvVarSection( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) { + IconButton( + onClick = { viewModel.removeEnvVar(server.name, key) }, + enabled = !server.isPluginOwned, + ) { Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp)) } } @@ -342,7 +360,10 @@ fun EnvVarSection( } } } else { - TextButton(onClick = { viewModel.startEditingEnv(server) }) { + TextButton( + onClick = { viewModel.startEditingEnv(server) }, + enabled = !server.isPluginOwned, + ) { Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp)) Spacer(modifier = Modifier.width(spacing.xs)) Text(stringResource(R.string.mcp_servers_env_add)) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index 5cbd407a..757ed094 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.InstallDesktop @@ -67,6 +69,7 @@ import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.toDetailRows import com.m57.hermescontrol.ui.plugins.components.CatalogPluginsView +import com.m57.hermescontrol.ui.plugins.components.PluginDetailDialog @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -120,6 +123,37 @@ fun PluginsScreen( ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) + state.updateConsent?.let { consent -> + AlertDialog( + onDismissRequest = viewModel::cancelPluginUpdate, + title = { Text(stringResource(R.string.plugins_update_consent_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(stringResource(R.string.plugins_update_consent_text, consent.name)) + val lines = consent.result.capabilityDelta + if (lines.isEmpty()) { + Text(consent.result.error ?: stringResource(R.string.plugins_update_consent_missing)) + } else { + lines.forEach { Text(it) } + } + } + }, + confirmButton = { + TextButton(onClick = viewModel::confirmPluginUpdate, enabled = state.rowBusy == null) { + Text(stringResource(R.string.plugins_update_accept)) + } + }, + dismissButton = { + TextButton(onClick = viewModel::cancelPluginUpdate) { + Text(stringResource(R.string.common_cancel)) + } + }, + ) + } + // Remove confirmation dialog if (state.removeConfirmPlugin != null) { AlertDialog( @@ -240,7 +274,7 @@ fun PluginsScreen( plugin = plugin, state = state, viewModel = viewModel, - onClick = { showDetail = plugin }, + onClick = { viewModel.openPluginDetail(plugin) }, ) } @@ -256,7 +290,10 @@ fun PluginsScreen( ) } items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin -> - OrphanPluginCard(plugin = plugin, onClick = { showDetail = plugin }) + OrphanPluginCard( + plugin = plugin, + onClick = { viewModel.openPluginDetail(plugin) }, + ) } } } @@ -280,11 +317,19 @@ fun PluginsScreen( } } - showDetail?.let { plugin -> - DetailDialog( - title = plugin.name, - rows = plugin.toDetailRows(), - onDismiss = { showDetail = null }, + state.activeDetailPlugin?.let { plugin -> + PluginDetailDialog( + plugin = plugin, + agentPluginRow = state.pluginDetailRow, + isLoading = state.isPluginDetailLoading, + isSaving = state.isPluginDetailSaving, + edits = state.pluginDetailEdits, + onFieldChange = { key, value -> viewModel.setPluginDetailEdit(key, value) }, + onSaveSettings = { viewModel.savePluginSettings() }, + onDismiss = { + showDetail = null + viewModel.closePluginDetail() + }, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index 10d50bde..137dcc7d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -6,15 +6,22 @@ import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.local.SwrCache import com.m57.hermescontrol.data.model.AgentPluginInstallBody +import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.model.PluginCatalogEntry import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginProvidersPutRequest +import com.m57.hermescontrol.data.model.PluginUpdateRequest +import com.m57.hermescontrol.data.model.PluginUpdateResult import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.ProviderOption import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.data.ws.AgentPluginRow +import com.m57.hermescontrol.data.ws.PluginManageRepository import com.m57.hermescontrol.data.ws.PluginRemovalRepository +import com.m57.hermescontrol.data.ws.PluginSettingField +import com.m57.hermescontrol.data.ws.PluginSettingFieldType import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.safeLaunchAction import com.m57.hermescontrol.ui.common.safeLaunchLoad @@ -27,12 +34,24 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull enum class PluginsTab { INSTALLED, CATALOG, } +data class PluginUpdateConsent( + val name: String, + val result: PluginUpdateResult, + val scope: DataScope?, +) + data class PluginsUiState( val selectedTab: PluginsTab = PluginsTab.INSTALLED, val isLoading: Boolean = false, @@ -55,6 +74,13 @@ data class PluginsUiState( val rowBusy: String? = null, // Confirm remove dialog val removeConfirmPlugin: String? = null, + val updateConsent: PluginUpdateConsent? = null, + // Active plugin detail with settings & diagnostics + val activeDetailPlugin: PluginInfo? = null, + val isPluginDetailLoading: Boolean = false, + val pluginDetailRow: AgentPluginRow? = null, + val pluginDetailEdits: Map = emptyMap(), + val isPluginDetailSaving: Boolean = false, // Rescan val rescanBusy: Boolean = false, // Catalog state @@ -76,6 +102,7 @@ data class PluginsUiState( class PluginsViewModel( private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(), + private val pluginManage: PluginManageRepository = PluginManageRepository(), ) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(PluginsUiState()) @@ -99,6 +126,10 @@ class PluginsViewModel( providerBusy = false, rowBusy = null, removeConfirmPlugin = null, + updateConsent = null, + activeDetailPlugin = null, + pluginDetailRow = null, + pluginDetailEdits = emptyMap(), rescanBusy = false, ) } @@ -450,23 +481,83 @@ class PluginsViewModel( } fun updatePlugin(name: String) { - viewModelScope.launch { - setRowBusy(name) - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.updatePlugin(name) } - } - when (result) { - is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Plugin updated successfully") } - clearRowBusy(name) - loadPlugins(forceRefresh = true) - } + if (_uiState.value.updateConsent != null) return + performPluginUpdate(name, acceptCapabilities = false) + } - is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") } - clearRowBusy(name) + fun cancelPluginUpdate() { + _uiState.update { it.copy(updateConsent = null) } + } + + fun confirmPluginUpdate() { + val consent = _uiState.value.updateConsent ?: return + if (_uiState.value.rowBusy != null) return + cancelPluginUpdate() + if (consent.scope != runCatching { AuthManager.currentDataScope() }.getOrNull()) return + performPluginUpdate(consent.name, acceptCapabilities = true) + } + + private fun performPluginUpdate( + name: String, + acceptCapabilities: Boolean, + ) { + if (_uiState.value.rowBusy != null) return + val generation = scopeGeneration + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + val api = ApiClient.hermesApi + setRowBusy(name) + _uiState.update { it.copy(toastMessage = null) } + viewModelScope.launch { + try { + val result = safeApiCall { api.updatePlugin(name, PluginUpdateRequest(acceptCapabilities)) } + if (generation != scopeGeneration || + requestScope != runCatching { AuthManager.currentDataScope() }.getOrNull() + ) { + return@launch } + when (result) { + is NetworkResult.Success -> { + val update = result.data + // #1282: HTTP success is not update success; consent must be explicit. + when { + update.consentRequired -> { + _uiState.update { + it.copy(updateConsent = PluginUpdateConsent(name, update, requestScope)) + } + } + + !update.ok -> { + _uiState.update { + it.copy( + toastMessage = "Failed to update plugin: ${update.error ?: "Update refused"}", + ) + } + } + + else -> { + _uiState.update { + it.copy( + toastMessage = + if (update.unchanged) { + "Plugin is already up to date" + } else { + "Plugin updated successfully" + }, + ) + } + loadPlugins(forceRefresh = true) + } + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") + } + } + } + } finally { + if (generation == scopeGeneration) clearRowBusy(name) } } } @@ -614,6 +705,198 @@ class PluginsViewModel( ) } + fun openPluginDetail(plugin: PluginInfo) { + _uiState.update { + it.copy( + activeDetailPlugin = plugin, + isPluginDetailLoading = true, + pluginDetailRow = null, + pluginDetailEdits = emptyMap(), + ) + } + viewModelScope.launch { + try { + val profile = AuthManager.activeProfileId.value + val rows = pluginManage.listPlugins(profile) + val matchingRow = rows.firstOrNull { it.key == plugin.name || it.name == plugin.name } + val initialEdits = mutableMapOf() + matchingRow?.settingsSchema?.forEach { field -> + if (field.type != PluginSettingFieldType.SECRET) { + val strVal = + when (val v = field.value ?: field.default) { + null -> "" + is JsonPrimitive -> v.content + else -> v.toString() + } + initialEdits[field.key] = strVal + } + } + _uiState.update { + it.copy( + isPluginDetailLoading = false, + pluginDetailRow = matchingRow, + pluginDetailEdits = initialEdits, + ) + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + _uiState.update { + it.copy( + isPluginDetailLoading = false, + pluginDetailRow = null, + ) + } + } + } + } + + fun closePluginDetail() { + _uiState.update { + it.copy( + activeDetailPlugin = null, + pluginDetailRow = null, + pluginDetailEdits = emptyMap(), + isPluginDetailSaving = false, + ) + } + } + + fun setPluginDetailEdit( + key: String, + value: String, + ) { + _uiState.update { + it.copy(pluginDetailEdits = it.pluginDetailEdits + (key to value)) + } + } + + fun savePluginSettings() { + val plugin = _uiState.value.pluginDetailRow ?: return + val schema = plugin.settingsSchema ?: return + val canonicalKey = plugin.key ?: plugin.name + val currentEdits = _uiState.value.pluginDetailEdits + + _uiState.update { it.copy(isPluginDetailSaving = true) } + viewModelScope.launch { + try { + val profile = AuthManager.activeProfileId.value + val valuesToSave = mutableMapOf() + + for (field in schema) { + val raw = currentEdits[field.key] ?: "" + when (field.type) { + PluginSettingFieldType.SECRET -> { + val envVar = field.env + if (raw.isNotBlank() && !envVar.isNullOrBlank()) { + val envResult = + safeApiCall { + ApiClient.hermesApi.updateEnvVar(EnvVarUpdate(key = envVar, value = raw)) + } + if (envResult is NetworkResult.Failure) { + throw IllegalStateException( + "Failed to update secret $envVar: ${envResult.error.message}", + ) + } + } + } + + PluginSettingFieldType.STRING -> { + valuesToSave[field.key] = JsonPrimitive(raw) + } + + PluginSettingFieldType.NUMBER -> { + val num = + raw.trim().toLongOrNull() + ?: raw.trim().toDoubleOrNull() + if (num != null) { + valuesToSave[field.key] = + if (num is Long) JsonPrimitive(num) else JsonPrimitive(num as Double) + } else if (raw.trim().isNotEmpty()) { + throw IllegalArgumentException("${field.label}: expected a number") + } + } + + PluginSettingFieldType.BOOLEAN -> { + valuesToSave[field.key] = JsonPrimitive(raw.trim().equals("true", ignoreCase = true)) + } + + PluginSettingFieldType.ENUM -> { + if (!field.choices.isNullOrEmpty() && raw.trim() !in field.choices) { + throw IllegalArgumentException( + "${field.label}: must be one of ${field.choices.joinToString()}", + ) + } + valuesToSave[field.key] = JsonPrimitive(raw.trim()) + } + + PluginSettingFieldType.JSON -> { + val parsed = + if (raw.trim().isEmpty()) { + JsonPrimitive("") + } else { + Json.parseToJsonElement(raw.trim()) + } + valuesToSave[field.key] = parsed + } + } + } + + if (valuesToSave.isNotEmpty()) { + val result = pluginManage.saveSettings(canonicalKey, valuesToSave, profile) + if (!result.ok) { + throw IllegalStateException(result.error ?: "Failed to save settings") + } + if (result.plugin != null) { + val refreshed = result.plugin + val newEdits = mutableMapOf() + refreshed.settingsSchema?.forEach { f -> + if (f.type != PluginSettingFieldType.SECRET) { + val strVal = + when (val v = f.value ?: f.default) { + null -> "" + is JsonPrimitive -> v.content + else -> v.toString() + } + newEdits[f.key] = strVal + } + } + _uiState.update { + it.copy( + isPluginDetailSaving = false, + pluginDetailRow = refreshed, + pluginDetailEdits = newEdits, + toastMessage = "Plugin settings saved", + ) + } + return@launch + } + } + + // If only secrets were saved or result plugin is null + _uiState.update { + it.copy( + isPluginDetailSaving = false, + pluginDetailEdits = + it.pluginDetailEdits.filterKeys { k -> + schema.none { f -> f.key == k && f.type == PluginSettingFieldType.SECRET } + }, + toastMessage = "Plugin settings saved", + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _uiState.update { + it.copy( + isPluginDetailSaving = false, + toastMessage = "Failed to save settings: ${e.message}", + ) + } + } + } + } + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/PluginDetailDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/PluginDetailDialog.kt new file mode 100644 index 00000000..68998a31 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/components/PluginDetailDialog.kt @@ -0,0 +1,397 @@ +package com.m57.hermescontrol.ui.plugins.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.PluginInfo +import com.m57.hermescontrol.data.ws.AgentPluginRow +import com.m57.hermescontrol.data.ws.PluginServerRow +import com.m57.hermescontrol.data.ws.PluginServerState +import com.m57.hermescontrol.data.ws.PluginSettingField +import com.m57.hermescontrol.data.ws.PluginSettingFieldType +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.common.DetailRow +import com.m57.hermescontrol.ui.common.DetailRowTone +import com.m57.hermescontrol.ui.common.ExposedDropdownField +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.common.toDetailRows +import com.m57.hermescontrol.ui.common.toneForStatus + +@Composable +fun PluginDetailDialog( + plugin: PluginInfo, + agentPluginRow: AgentPluginRow?, + isLoading: Boolean, + isSaving: Boolean, + edits: Map, + onFieldChange: (String, String) -> Unit, + onSaveSettings: () -> Unit, + onDismiss: () -> Unit, +) { + val statusColors = LocalHermesStatusColors.current + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + HermesScaffold( + title = { Text(plugin.name, style = MaterialTheme.typography.titleLarge) }, + navigationIcon = NavIcon.Back(onDismiss), + isRefreshing = false, + ) { _ -> + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Standard metadata rows + val rows = plugin.toDetailRows().filter { !it.value.isNullOrBlank() } + rows.forEachIndexed { index, (label, value, tone) -> + if (index > 0) { + HorizontalDivider() + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value ?: "", + style = MaterialTheme.typography.bodyMedium, + color = + when (tone) { + DetailRowTone.SUCCESS -> statusColors.success + DetailRowTone.WARNING -> statusColors.warning + DetailRowTone.ERROR -> statusColors.error + DetailRowTone.INFO -> statusColors.info + DetailRowTone.NEUTRAL -> statusColors.neutral + DetailRowTone.NONE -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + if (isLoading) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.plugins_settings_loading), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Diagnostics section (only if servers present) + val servers = agentPluginRow?.servers.orEmpty() + if (servers.isNotEmpty()) { + HorizontalDivider() + Text( + text = stringResource(R.string.plugins_diagnostics_heading), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + servers.forEach { server -> + PluginServerDiagnosticRow(server = server) + } + } + + // Settings section (only if schema present) + val schema = agentPluginRow?.settingsSchema.orEmpty() + if (schema.isNotEmpty()) { + HorizontalDivider() + Text( + text = stringResource(R.string.plugins_settings_heading), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + schema.forEach { field -> + PluginSettingFieldRow( + field = field, + value = edits[field.key] ?: "", + enabled = !isSaving, + onChange = { onFieldChange(field.key, it) }, + ) + } + + Button( + onClick = onSaveSettings, + enabled = !isSaving, + modifier = Modifier.align(Alignment.End), + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.plugins_settings_save)) + } + } + + // Close action + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_close)) + } + } + } + } + } + } +} + +@Composable +private fun PluginServerDiagnosticRow(server: PluginServerRow) { + val statusColors = LocalHermesStatusColors.current + val (statusLabel, statusColor, badgeType) = + when (server.state) { + PluginServerState.CONNECTED -> { + Triple( + stringResource(R.string.plugins_server_state_connected), + statusColors.success, + StatusBadgeType.SUCCESS, + ) + } + + PluginServerState.APP_NOT_RUNNING -> { + Triple( + stringResource(R.string.plugins_server_state_app_not_running), + statusColors.warning, + StatusBadgeType.WARNING, + ) + } + + PluginServerState.ENDPOINT_UNAVAILABLE -> { + Triple( + stringResource(R.string.plugins_server_state_endpoint_unavailable), + statusColors.warning, + StatusBadgeType.WARNING, + ) + } + + PluginServerState.NO_INTERACTIVE_SESSION -> { + Triple( + stringResource(R.string.plugins_server_state_no_interactive_session), + statusColors.warning, + StatusBadgeType.WARNING, + ) + } + + PluginServerState.VERSION_TOO_OLD -> { + Triple( + stringResource(R.string.plugins_server_state_version_too_old), + statusColors.error, + StatusBadgeType.ERROR, + ) + } + + PluginServerState.MISSING_APP -> { + Triple( + stringResource(R.string.plugins_server_state_missing_app), + statusColors.error, + StatusBadgeType.ERROR, + ) + } + + PluginServerState.UNKNOWN -> { + Triple( + stringResource(R.string.plugins_server_state_unknown), + statusColors.warning, + StatusBadgeType.WARNING, + ) + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = server.name, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + StatusBadge(text = statusLabel, status = badgeType) + } + if (server.sentence.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = server.sentence, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun PluginSettingFieldRow( + field: PluginSettingField, + value: String, + enabled: Boolean, + onChange: (String) -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = field.label.ifEmpty { field.key }, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + if (field.type == PluginSettingFieldType.SECRET) { + val isSet = field.hasValue == true + val badgeRes = + if (isSet) { + R.string.plugins_settings_secret_set + } else { + R.string.plugins_settings_secret_not_set + } + StatusBadge( + text = stringResource(badgeRes), + status = if (isSet) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING, + ) + } + } + if (field.description.isNotBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = field.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (field.type == PluginSettingFieldType.SECRET && !field.env.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(R.string.plugins_settings_secret_hint, field.env), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + + when (field.type) { + PluginSettingFieldType.ENUM -> { + ExposedDropdownField( + label = field.label.ifEmpty { field.key }, + options = field.choices.orEmpty().ifEmpty { listOf(value) }, + selectedValue = value, + onOptionSelected = onChange, + ) + } + + PluginSettingFieldType.BOOLEAN -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + checked = value.equals("true", ignoreCase = true), + onCheckedChange = { onChange(it.toString()) }, + enabled = enabled, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = + if (value.equals("true", ignoreCase = true)) { + stringResource(R.string.memory_provider_bool_on) + } else { + stringResource(R.string.memory_provider_bool_off) + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + else -> { + OutlinedTextField( + value = value, + onValueChange = onChange, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + label = { + Text( + if (field.type == PluginSettingFieldType.SECRET) { + stringResource(R.string.memory_provider_secret_label) + } else { + field.label.ifEmpty { field.key } + }, + ) + }, + singleLine = field.type != PluginSettingFieldType.JSON, + visualTransformation = + if (field.type == PluginSettingFieldType.SECRET) { + PasswordVisualTransformation() + } else { + androidx.compose.ui.text.input.VisualTransformation.None + }, + keyboardOptions = + if (field.type == PluginSettingFieldType.NUMBER) { + KeyboardOptions(keyboardType = KeyboardType.Decimal) + } else { + KeyboardOptions.Default + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 3f00fcb2..5ea87d77 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -711,6 +711,17 @@ fun SessionsScreen( ) } + state.sessions.isEmpty() && state.corruptStorageProfiles.isNotEmpty() -> { + ErrorState( + message = + stringResource( + R.string.sessions_storage_corrupt, + state.corruptStorageProfiles.sorted().joinToString(", "), + ), + onRetry = { viewModel.loadSessions(forceRefresh = true) }, + ) + } + state.sessions.isEmpty() -> { EmptyState( title = @@ -752,6 +763,18 @@ fun SessionsScreen( else -> { Column(modifier = Modifier.fillMaxSize()) { + if (state.corruptStorageProfiles.isNotEmpty()) { + Text( + text = + stringResource( + R.string.sessions_storage_corrupt, + state.corruptStorageProfiles.sorted().joinToString(", "), + ), + color = statusColors.warning, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(spacing.md), + ) + } // ── Stats row ─────────────────────────────────────── SessionsStatsRow( total = state.total, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 30c20414..87363b65 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -81,6 +81,7 @@ data class SessionsUiState( val total: Int = 0, val hasMore: Boolean = false, val errorMessage: String? = null, + val corruptStorageProfiles: Set = emptySet(), val stats: SessionStats = SessionStats(), val isLoadingStats: Boolean = false, val statsError: String? = null, @@ -197,6 +198,7 @@ class SessionsViewModel( val (requestScope, capturedSection) = scopeAndSection val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() if (requestGeneration == generation && currentScope == requestScope) { + if (handleStorageHealth(data)) return@refreshOnChange val localKey = "${capturedSection.name}:${capturedSection.source}:${capturedSection.excludeSources}" val inMemoryKey = requestScope?.inMemoryKey(localKey) val persistentKey = requestScope?.persistentKey(localKey) @@ -244,6 +246,7 @@ class SessionsViewModel( selectedIds = emptySet(), isSelecting = false, errorMessage = null, + corruptStorageProfiles = emptySet(), searchResults = emptyList(), searchError = null, searchLoadMoreError = null, @@ -342,6 +345,7 @@ class SessionsViewModel( total = 0, hasMore = false, errorMessage = null, + corruptStorageProfiles = emptySet(), isSelecting = false, selectedIds = emptySet(), isSearching = false, @@ -352,6 +356,26 @@ class SessionsViewModel( if (query.isBlank()) loadSessions() else setSearchQuery(query) } + // #1286: a partial/empty damaged-store page is not an authoritative replacement. + private fun handleStorageHealth(data: SessionListResponse): Boolean { + val corruptProfiles = data.storage.filterValues { it == "corrupt" }.keys + _uiState.update { state -> + if (corruptProfiles.isEmpty()) { + state.copy(corruptStorageProfiles = emptySet()) + } else { + state.copy( + isLoading = false, + isLoadingMore = false, + errorMessage = null, + corruptStorageProfiles = corruptProfiles, + sessions = (state.sessions + data.sessions).distinctBy { it.id }, + hasMore = false, + ) + } + } + return corruptProfiles.isNotEmpty() + } + /** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */ fun loadSessions(forceRefresh: Boolean = false) { val requestGeneration = generation @@ -360,10 +384,7 @@ class SessionsViewModel( val localKey = "${section.name}:${section.source}:${section.excludeSources}" val inMemoryKey = requestScope?.inMemoryKey(localKey) val persistentKey = requestScope?.persistentKey(localKey) - if (forceRefresh) { - if (inMemoryKey != null) sessionsPageCache.remove(inMemoryKey) - if (persistentKey != null) SessionListCacheStore.remove(persistentKey) - } + // #1286: keep the last good cache until a healthy refresh replaces it. val cached = if (!forceRefresh && inMemoryKey != null && persistentKey != null) { sessionsPageCache.get(inMemoryKey) @@ -418,6 +439,7 @@ class SessionsViewModel( onSuccess = { data -> val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() if (requestGeneration != generation || currentScope != requestScope) return@safeLaunchLoad + if (handleStorageHealth(data)) return@safeLaunchLoad if (inMemoryKey != null) sessionsPageCache.put(inMemoryKey, data) if (persistentKey != null) SessionListCacheStore.put(persistentKey, data) rawPaginationOffset = data.nextOffset(0) @@ -495,6 +517,7 @@ class SessionsViewModel( when (result) { is NetworkResult.Success -> { val data = result.data + if (handleStorageHealth(data)) return@launch rawPaginationOffset = data.nextOffset(offset) _uiState.update { val newSessions = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index 6ea47d43..8669d16c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ListAlt import androidx.compose.material.icons.filled.ChatBubbleOutline +import androidx.compose.material.icons.filled.Extension import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Lock @@ -34,6 +35,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.AccountConnectorsScreen import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.SettingsAbout @@ -87,6 +89,12 @@ fun SettingsScreen( }, onClick = { NavigationController.navigateTo(SettingsConnection) }, ), + SettingsRow( + icon = Icons.Filled.Extension, + label = stringResource(R.string.screen_account_connectors), + summary = stringResource(R.string.account_connectors_summary), + onClick = { NavigationController.navigateTo(AccountConnectorsScreen) }, + ), SettingsRow( icon = Icons.Filled.Palette, label = stringResource(R.string.settings_sec_appearance), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 411f45bb..e2ebd227 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -265,6 +265,15 @@ class SettingsViewModel( ApiClient.rebuild() } + fun onCustomHeadersSaved() { + val editedEndpoint = ServerEndpoint.parseForBuild(_uiState.value.dialogProfileBaseUrl) + if (editedEndpoint.baseUrl == AuthManager.endpointForBuild().baseUrl) { + ApiClient.rebuild() + HermesWsClient.disconnect() + HermesWsClient.connect() + } + } + // ── Delete confirmation ────────────────────────────────────────────── fun requestDeleteProfile(profileId: String) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt index e1e86ed9..9200a2e7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt @@ -175,6 +175,7 @@ internal fun ConnectionSection( onTokenChange = viewModel::onDialogProfileTokenChange, onSave = viewModel::saveProfileFromDialog, onDismiss = viewModel::closeProfileDialog, + onHeadersSaved = viewModel::onCustomHeadersSaved, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt index 64137ab9..ab0207a1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.common.CustomHeadersButton @Composable internal fun ProfileEditorDialog( @@ -33,6 +34,7 @@ internal fun ProfileEditorDialog( onTokenChange: (String) -> Unit, onSave: () -> Unit, onDismiss: () -> Unit, + onHeadersSaved: () -> Unit, ) { val title = if (isEditing) { @@ -63,6 +65,7 @@ internal fun ProfileEditorDialog( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = Modifier.fillMaxWidth(), ) + CustomHeadersButton(baseUrl = baseUrl, onSaved = onHeadersSaved) if (error != null) { Text( text = error, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 858a5b53..15e5b90e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1557,6 +1557,24 @@ %1$s مطلوب تعذر إرسال رد الإعداد. حاول مرة أخرى. بانتظار Hermes… + تثبيت %1$s + إضافة + مهارة + رسمي + مجتمعي + المصدر: %1$s + الإيداع المثبّت: %1$s + المنصات: %1$s + المتطلبات: %1$s + يُثبَّت في الملف الشخصي: %1$s + اجتاز الفحص الأمني + وجد الفحص الأمني تحذيرات + فشل الفحص الأمني + لم يُبلَّغ عن فحص أمني + تثبيت + حاول مجددًا + جارٍ التثبيت… + المهارة متاحة: %1$s أثناء عمل الوكيل اختر كيفية وصول الرسالة الجديدة إلى المحادثة الحالية. تُرسل الرسائل بشكل عادي عند الخمول. diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d85ee3c7..c659080f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1213,6 +1213,24 @@ %1$s은(는) 필수입니다 설정 응답을 보낼 수 없습니다. 다시 시도하세요. Hermes를 기다리는 중… + %1$s 설치 + 플러그인 + 스킬 + 공식 + 커뮤니티 + 소스: %1$s + 고정 커밋: %1$s + 플랫폼: %1$s + 요구 사항: %1$s + 설치 대상 프로필: %1$s + 보안 검사 통과 + 보안 검사에서 경고 발견 + 보안 검사 실패 + 보고된 보안 검사 없음 + 설치 + 다시 시도 + 설치 중… + 사용 가능한 스킬: %1$s 작업 지정 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 43541c9f..eac2130b 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1509,6 +1509,24 @@ %1$s为必填项 无法发送设置响应,请重试。 正在等待 Hermes… + 安装 %1$s + 插件 + 技能 + 官方 + 社区 + 来源:%1$s + 固定提交:%1$s + 平台:%1$s + 要求:%1$s + 安装到配置文件:%1$s + 安全扫描已通过 + 安全扫描发现警告 + 安全扫描失败 + 未报告安全扫描 + 安装 + 重试 + 正在安装… + 可用技能:%1$s 智能体工作时 选择新消息如何送达当前聊天。空闲时正常发送。 修正当前轮次 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5fcd2512..c05a133d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,8 +4,12 @@ Send Listening… + Cancel + Slide up to lock recording Could not start voice recording - Slide to cancel + Send voice note + cancel + Slide to Stop generating Microphone permission is required for voice input Speech recognition is not available on this device @@ -83,6 +87,7 @@ Cron System Settings + Connectors Profiles Bots Active Now @@ -232,6 +237,11 @@ Scroll to bottom Copy Copied + Read aloud + Stop speaking + Could not reach the Hermes server for speech + Could not synthesize the message audio + Could not play the message audio Edit %1$s tok %1$s t/s @@ -244,6 +254,17 @@ Connection + Account connectors + Manage account authorizations and tool access without a chat session. + Authorized accounts + Connect + Tools + Enabled + Not connected + No authorized connector accounts. + Remove + Remove connector account? + Remove %1$s? You may need to authorize it again. Auth Token Stay connected in background Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery. @@ -1047,6 +1068,7 @@ No description provided. Update Uninstall + Settings Enable Install Remove @@ -1062,6 +1084,25 @@ Remove Plugin This will remove the "%1$s" plugin from your agent. Auth Required + Loading plugin settings… + Plugin Settings + Save Settings + Plugin settings saved + Failed to save settings: %1$s + Diagnostics + No diagnostics reported + Settings + set + not set + Stored in .env as %1$s (leave blank to keep) + No configurable settings + Connected + App not running + Endpoint unavailable + No interactive session + Version too old + App missing + Status unknown Auth command: %1$s Dashboard slots: %1$s Orphaned Dashboard Plugins @@ -1115,6 +1156,8 @@ Transport: %1$s + From plugin %1$s · Read-only + Plugin-managed · Read-only Command: Test Test All @@ -1736,6 +1779,24 @@ %1$s is required Couldn’t send the setup response. Try again. Waiting for Hermes… + Install %1$s + Plugin + Skill + Official + Community + Source: %1$s + Pinned commit: %1$s + Platforms: %1$s + Requires: %1$s + Installs into profile: %1$s + Security scan passed + Security scan found warnings + Security scan failed + No security scan reported + Install + Try again + Installing… + Skill available: %1$s Specify task @@ -1763,4 +1824,24 @@ Attachment error: %1$s Active workers No active workers + Custom headers + Saved securely for this server URL. Use Cloudflare Access service-token headers here. Hermes login is still required. + Header name + Header value + Add header + Add Cloudflare headers + Remove header + Remove this header? Save your changes to apply the removal. + Show header values + Hide header values + Use unique HTTP header names and single-line ASCII values. Authorization, Cookie, Host, and connection headers are managed by the app. + Could not save custom headers. Try again. + Review new capabilities + Updating %1$s requires the additional capabilities below. Nothing has been installed yet. Accept these capabilities and update? + The server did not provide capability details. + Accept and update + Session storage is damaged for %1$s. History may be incomplete; previously loaded sessions are kept. Restore or recover the database on the server, then refresh. + Reconnect + No tools available + Read-only policy restrictions diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt index 05c58e06..71449d7e 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt @@ -35,6 +35,7 @@ class AuthManagerTest { @Before fun setUp() { mockPrefs = mockk(relaxed = true) + every { mockPrefs.getString("server_custom_headers", null) } returns null mockEditor = mockk(relaxed = true) mockContext = mockk(relaxed = true) diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt index cf8fb9bc..afc5108a 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt @@ -46,6 +46,7 @@ class Issue647ProfileUrlTest { @Before fun setUp() { mockPrefs = mockk(relaxed = true) + every { mockPrefs.getString("server_custom_headers", null) } returns null mockEditor = mockk(relaxed = true) mockContext = mockk(relaxed = true) diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/TtsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/TtsTest.kt new file mode 100644 index 00000000..1c0f3515 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/TtsTest.kt @@ -0,0 +1,140 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TtsTest { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + @Test + fun `serialize TtsSpeakRequest with literal text`() { + val request = TtsSpeakRequest(text = "Hello, world!") + val encoded = json.encodeToString(request) + + assertEquals("""{"text":"Hello, world!"}""", encoded) + } + + @Test + fun `deserialize TtsSpeakResponse with all fields present`() { + val rawJson = + """ + { + "ok": true, + "data_url": "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=", + "mime_type": "audio/wav", + "provider": "piper" + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertTrue(response.ok) + assertEquals( + "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=", + response.data_url, + ) + assertEquals("audio/wav", response.mime_type) + assertEquals("piper", response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with optional provider missing`() { + val rawJson = + """ + { + "ok": true, + "data_url": "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/", + "mime_type": "audio/mp3" + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertTrue(response.ok) + assertEquals( + "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/", + response.data_url, + ) + assertEquals("audio/mp3", response.mime_type) + assertNull(response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with provider explicitly null`() { + val rawJson = + """ + { + "ok": true, + "data_url": "data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=", + "mime_type": "audio/ogg", + "provider": null + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertTrue(response.ok) + assertEquals("data:audio/ogg;base64,T2dnUwACAAAAAAAAAAA=", response.data_url) + assertEquals("audio/ogg", response.mime_type) + assertNull(response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with missing ok defaults to false`() { + val rawJson = + """ + { + "data_url": null, + "mime_type": null + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertFalse(response.ok) + assertNull(response.data_url) + assertNull(response.mime_type) + assertNull(response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with empty json defaults to ok false and nulls`() { + val rawJson = "{}" + + val response = json.decodeFromString(rawJson) + + assertFalse(response.ok) + assertNull(response.data_url) + assertNull(response.mime_type) + assertNull(response.provider) + } + + @Test + fun `deserialize TtsSpeakResponse with unknown fields ignores extra properties`() { + val rawJson = + """ + { + "ok": false, + "error": "Provider not reachable", + "status_code": 500 + } + """.trimIndent() + + val response = json.decodeFromString(rawJson) + + assertFalse(response.ok) + assertNull(response.data_url) + assertNull(response.mime_type) + assertNull(response.provider) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ServerHeadersTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ServerHeadersTest.kt new file mode 100644 index 00000000..be8f1367 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ServerHeadersTest.kt @@ -0,0 +1,297 @@ +package com.m57.hermescontrol.data.remote + +import android.content.SharedPreferences +import io.mockk.every +import io.mockk.mockk +import okhttp3.CookieJar +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class ServerHeadersTest { + private val preferences = mockk() + private val editor = mockk() + private var saved: String? = null + private lateinit var server: MockWebServer + private val headers = + CustomHeaders.parse( + listOf( + "CF-Access-Client-Id" to "test-id", + "CF-Access-Client-Secret" to "secret", + ), + ) + + @Before + fun setUp() { + every { preferences.getString(any(), null) } answers { saved } + every { preferences.edit() } returns editor + every { editor.putString(any(), any()) } answers { + saved = secondArg() + editor + } + every { editor.commit() } returns true + ServerHeaders.initialize(preferences) + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + saved = null + ServerHeaders.initialize(preferences) + server.shutdown() + } + + @Test + fun `protected probe login ticket API and media requests require both headers`() { + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + if (request.getHeader("CF-Access-Client-Id") == "test-id" && + request.getHeader("CF-Access-Client-Secret") == "secret" + ) { + MockResponse().setBody("{}") + } else { + MockResponse().setResponseCode(403) + } + } + val url = server.url("/hermes/") + val probe = Request.Builder().url(server.url("/hermes/api/status")).build() + OkHttpProvider.probe + .newCall(probe) + .execute() + .use { assertEquals(403, it.code) } + ServerHeaders.save(url, headers) + val paths = listOf("api/status", "auth/password-login", "api/auth/ws-ticket", "api/sessions", "files/image.png") + paths.forEach { path -> + val builder = Request.Builder().url(server.url("/hermes/$path")) + if (path.contains("login") || path.contains("ticket")) builder.post("{}".toRequestBody()) + listOf(OkHttpProvider.probe, OkHttpProvider.base).forEach { client -> + client.newCall(builder.build()).execute().use { assertEquals(200, it.code) } + } + } + ServerHeaders.save(url, CustomHeaders.EMPTY) + OkHttpProvider.probe + .newCall(probe) + .execute() + .use { assertEquals(403, it.code) } + } + + @Test + fun `custom headers coexist with Hermes bearer and cookie authentication`() { + ServerHeaders.save(server.url("/"), headers) + server.enqueue(MockResponse()) + val request = + Request + .Builder() + .url(server.url("/api/status")) + .header("Authorization", "Bearer hermes-token") + .header("Cookie", "hermes_session_at=session") + .build() + OkHttpProvider.base + .newBuilder() + .cookieJar(CookieJar.NO_COOKIES) + .build() + .newCall(request) + .execute() + .close() + val recorded = server.takeRequest() + assertEquals("test-id", recorded.getHeader("CF-Access-Client-Id")) + assertEquals("Bearer hermes-token", recorded.getHeader("Authorization")) + assertEquals("hermes_session_at=session", recorded.getHeader("Cookie")) + } + + @Test + fun `WebSocket upgrade carries custom headers and receives a message`() { + ServerHeaders.save(server.url("/hermes/"), headers) + val message = CountDownLatch(1) + server.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + webSocket.send("connected") + } + }, + ), + ) + val socket = + OkHttpProvider.websocket.newWebSocket( + Request.Builder().url(server.url("/hermes/api/ws?ticket=test")).build(), + object : WebSocketListener() { + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + if (text == "connected") message.countDown() + } + }, + ) + try { + assertTrue(message.await(5, TimeUnit.SECONDS)) + val recorded = server.takeRequest() + assertEquals("test-id", recorded.getHeader("CF-Access-Client-Id")) + assertEquals("secret", recorded.getHeader("CF-Access-Client-Secret")) + assertEquals("websocket", recorded.getHeader("Upgrade")) + } finally { + socket.cancel() + } + } + + @Test + fun `HTTP redirect to another configured server sends neither servers credentials`() { + MockWebServer().use { other -> + other.start() + ServerHeaders.save(server.url("/"), headers) + ServerHeaders.save(other.url("/"), CustomHeaders.parse(listOf("X-Other-Secret" to "other-secret"))) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", other.url("/api/status"))) + other.enqueue(MockResponse()) + OkHttpProvider.base + .newCall(Request.Builder().url(server.url("/api/status")).build()) + .execute() + .close() + assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret")) + val redirected = other.takeRequest() + assertNull(redirected.getHeader("CF-Access-Client-Secret")) + assertNull(redirected.getHeader("CF-Access-Client-Id")) + assertNull(redirected.getHeader("X-Other-Secret")) + } + } + + @Test + fun `same-server redirect preserves headers but escaping path prefix removes them`() { + ServerHeaders.save(server.url("/hermes/"), headers) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/hermes/next"))) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/outside"))) + server.enqueue(MockResponse()) + OkHttpProvider.base + .newCall(Request.Builder().url(server.url("/hermes/start")).build()) + .execute() + .close() + assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret")) + assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret")) + assertNull(server.takeRequest().getHeader("CF-Access-Client-Secret")) + } + + @Test + fun `redirect into a separately configured path does not inherit parent credentials`() { + ServerHeaders.save(server.url("/"), headers) + ServerHeaders.save(server.url("/public/"), CustomHeaders.EMPTY) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/public/api/status"))) + server.enqueue(MockResponse()) + OkHttpProvider.base + .newCall(Request.Builder().url(server.url("/start")).build()) + .execute() + .close() + assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret")) + assertNull(server.takeRequest().getHeader("CF-Access-Client-Secret")) + } + + @Test + fun `WebSocket redirect is not followed`() { + MockWebServer().use { other -> + other.start() + ServerHeaders.save(server.url("/"), headers) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", other.url("/api/ws"))) + val failed = CountDownLatch(1) + val socket = + OkHttpProvider.websocket.newWebSocket( + Request.Builder().url(server.url("/api/ws")).build(), + object : WebSocketListener() { + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + if (response?.code == 302) failed.countDown() + } + }, + ) + try { + assertTrue(failed.await(5, TimeUnit.SECONDS)) + assertEquals(0, other.requestCount) + } finally { + socket.cancel() + } + } + } + + @Test + fun `scope matches scheme host port and complete path segment`() { + val scope = ScopedServerHeaders("https://hermes.example/app/".toHttpUrl(), headers) + assertTrue(scope.contains("https://hermes.example/app/api/status".toHttpUrl())) + assertTrue(scope.contains("https://hermes.example/app".toHttpUrl())) + listOf( + "http://hermes.example/app/api/status", + "https://other.example/app/api/status", + "https://hermes.example:8443/app/api/status", + "https://hermes.example/application/api/status", + ).forEach { assertFalse(scope.contains(it.toHttpUrl())) } + } + + @Test + fun `saved credentials survive reload and an empty child scope blocks parent credentials`() { + ServerHeaders.save(server.url("/"), headers) + ServerHeaders.save(server.url("/public/"), CustomHeaders.EMPTY) + ServerHeaders.initialize(preferences) + assertEquals("secret", ServerHeaders.get(server.url("/")).entries["CF-Access-Client-Secret"]) + assertTrue( + ServerHeaders + .forRequest(server.url("/public/api/status"))!! + .headers.entries + .isEmpty(), + ) + } + + @Test + fun `failed storage write does not publish new credentials`() { + every { editor.commit() } returns false + assertThrows(IOException::class.java) { ServerHeaders.save(server.url("/"), headers) } + assertTrue(ServerHeaders.get(server.url("/")).entries.isEmpty()) + } + + @Test + fun `malformed stored JSON does not expose credentials in errors`() { + saved = "{\"secret\": broken" + val error = assertThrows(IOException::class.java) { ServerHeaders.initialize(preferences) } + assertFalse(error.stackTraceToString().contains("secret")) + } + + @Test + fun `reject invalid duplicate and managed headers without revealing values`() { + listOf( + listOf("Bad Name" to "secret"), + listOf("X-Test" to "secret\r\nInjected: bad"), + listOf("X-Test" to "secret", "x-test" to "secret"), + listOf("Authorization" to "secret"), + listOf("Cookie" to "secret"), + listOf("Host" to "secret"), + listOf("Sec-WebSocket-Key" to "secret"), + ).forEach { rows -> + val error = assertThrows(IllegalArgumentException::class.java) { CustomHeaders.parse(rows) } + assertFalse(error.message.orEmpty().contains("secret")) + } + assertFalse(headers.toString().contains("secret")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt index e1387b9a..1dce6cb8 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt @@ -16,7 +16,9 @@ import org.junit.Assert.assertTrue import org.junit.Test import retrofit2.Response import java.io.File +import java.net.ConnectException import java.net.SocketTimeoutException +import java.net.UnknownHostException class VoiceNoteRepositoryTest { private val api = mockk() @@ -73,6 +75,40 @@ class VoiceNoteRepositoryTest { coVerify(exactly = 1) { api.transcribeAudio(any()) } } + @Test + fun transcribe_retriesOnceWhenTheConnectNeverEstablished() = + runTest { + // A connect failure means the request never reached the server, so + // one re-attempt is safe and rescues Wi-Fi/cellular handoffs that + // surface as "Network is unreachable" (device follow-up, #1247). + val file = tempVoiceFile() + var attempts = 0 + coEvery { api.transcribeAudio(any()) } coAnswers { + attempts++ + if (attempts == 1) { + throw ConnectException("failed to connect to /192.168.1.10:8000") + } + Response.success(AudioTranscriptionResponse(ok = true, transcript = " hi ")) + } + + val result = VoiceNoteRepository { _ -> api }.transcribe(file) + + assertEquals("hi", (result as NetworkResult.Success).data) + coVerify(exactly = 2) { api.transcribeAudio(any()) } + } + + @Test + fun transcribe_givesUpAfterOneConnectRetry() = + runTest { + val file = tempVoiceFile() + coEvery { api.transcribeAudio(any()) } throws UnknownHostException("no dns") + + val result = VoiceNoteRepository { _ -> api }.transcribe(file) + + assertTrue(result is NetworkResult.Failure) + coVerify(exactly = 2) { api.transcribeAudio(any()) } + } + @Test fun transcribe_resolvesTheServiceAtCallTime_soAConnectionSwitchUsesTheNewService() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt new file mode 100644 index 00000000..c13f06b0 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt @@ -0,0 +1,255 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.AccountConnectorResult +import com.m57.hermescontrol.data.model.ConnectorTool +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class AccountConnectorRepositoryTest { + @Before fun setup() { + mockkObject(AuthManager) + every { AuthManager.activeProfileId } returns MutableStateFlow("work") + every { AuthManager.currentDataScope() } returns null + } + + @After fun teardown() = unmockkAll() + + @Test fun `connect decodes operation snapshot and preserves authorization link without a session`() = + runTest { + val calls = mutableListOf>>() + val repo = + HermesAccountConnectorRepository { method, params -> + calls += method to params + operationFixture() + } + val result = repo.connect(listOf("drive"), true) as AccountConnectorResult.Success + assertTrue(result.value.accountOwned) + assertEquals("op-a", result.value.opId) + assertEquals( + "https://example.com/authorize", + result.value.targets + .single() + .safeConnectUrl, + ) + assertEquals( + mapOf( + "profile" to "work", + "owner" to mapOf("type" to "account"), + "connectors" to listOf("drive"), + "reconnect" to true, + ), + calls.single().second, + ) + assertEquals(WsMethods.CONNECTORS_CONNECT, calls.single().first) + } + + @Test fun `account operation respond status and wake retain profile and owner`() = + runTest { + val calls = mutableListOf>>() + val repo = + HermesAccountConnectorRepository { method, params -> + calls += method to params + operationFixture() + } + repo.operationStatus("op-a") + repo.operationRequest( + WsMethods.CONNECTION_RESPOND, + mapOf( + "op_id" to "op-a", + "result" to mapOf("settled_by" to "continue"), + ), + ) + repo.operationRequest(WsMethods.CONNECTORS_OPERATION_WAKE, mapOf("op_id" to "op-a")) + assertEquals( + listOf( + WsMethods.CONNECTORS_OPERATION_STATUS, + WsMethods.CONNECTION_RESPOND, + WsMethods.CONNECTORS_OPERATION_WAKE, + ), + calls.map { + it.first + }, + ) + calls.forEach { (_, params) -> + assertEquals("work", params["profile"]) + assertEquals(mapOf("type" to "account"), params["owner"]) + assertEquals("op-a", params["op_id"]) + assertFalse("session_id" in params) + } + } + + @Test fun `catalog accounts tools and removal use native account endpoints`() = + runTest { + val calls = mutableListOf>>() + val repo = + HermesAccountConnectorRepository { method, params -> + calls += method to params + when (method) { + WsMethods.CONNECTORS_CATALOG -> { + mapOf( + "connectors" to + listOf( + mapOf( + "slug" to "drive", + "name" to "Google Drive", + "description" to "Files", + "category" to "storage", + ), + ), + ) + } + + WsMethods.CONNECTORS_ACCOUNTS -> { + mapOf( + "accounts" to + listOf( + mapOf( + "connection_id" to "conn-1", + "connector" to "drive", + "label" to "Work account", + "status" to "active", + "active" to true, + "alias" to null, + ), + ), + ) + } + + WsMethods.CONNECTORS_TOOLS -> { + mapOf( + "tools" to + listOf( + mapOf( + "slug" to "delete", + "name" to "Delete file", + "hints" to listOf("destructive"), + ), + ), + ) + } + + WsMethods.CONNECTORS_ACCOUNTS_REMOVE -> { + mapOf( + "connection_id" to "conn-1", + "connector" to "drive", + "status" to "removed", + ) + } + + else -> { + error(method) + } + } + } + assertEquals("Google Drive", (repo.catalog() as AccountConnectorResult.Success).value.single().name) + assertEquals("Work account", (repo.accounts() as AccountConnectorResult.Success).value.single().label) + assertEquals( + "Delete file", + (repo.tools("drive", true) as AccountConnectorResult.Success).value.single().name, + ) + assertTrue(repo.remove("conn-1") is AccountConnectorResult.Success) + assertTrue(calls.all { "owner" !in it.second && it.second["profile"] == "work" }) + assertEquals(mapOf("profile" to "work", "slug" to "drive", "refresh" to true), calls[2].second) + assertEquals(mapOf("profile" to "work", "connection_id" to "conn-1"), calls[3].second) + } + + @Test fun `policy keeps member revision separate from effective and respects inherited rules`() = + runTest { + val repo = HermesAccountConnectorRepository { _, _ -> policyFixture() } + val policy = (repo.policy() as AccountConnectorResult.Success).value + assertEquals("effective-revision", policy.revision) + assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", policy.member?.revision) + assertEquals(listOf("mine"), policy.member?.tools?.get("drive")) + val destructive = ConnectorTool("delete", "Delete", "", "destructive", false, listOf("destructive")) + assertFalse(policy.inherited.single().toolEnabled("drive", destructive)) + assertFalse(policy.copy(mode = "deny-all").connectorEnabled("drive")) + assertFalse(policy.copy(mode = "future-mode").connectorEnabled("drive")) + } + + @Test fun `writes use observed member revision not effective revision or fresh overwritten version`() = + runTest { + val calls = mutableListOf>>() + val repo = + HermesAccountConnectorRepository { method, params -> + calls += method to params + if (method == WsMethods.CONNECTORS_POLICY_GET) { + policyFixture() + } else { + mapOf( + "revision" to "new-member-revision", + "effective" to mapOf("mode" to "deny-all", "revision" to "effective-revision"), + ) + } + } + assertTrue( + repo.setDisabledTools( + "drive", + listOf("delete"), + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + ) is AccountConnectorResult.Success, + ) + assertEquals(WsMethods.CONNECTORS_POLICY_SET, calls.first().first) + assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", calls.first().second["expected_revision"]) + assertEquals( + mapOf("type" to "tools", "connector" to "drive", "disabled_tools" to listOf("delete")), + calls.first().second["change"], + ) + } + + @Test fun `malformed authorization does not look like a successful connect`() = + runTest { + val repo = HermesAccountConnectorRepository { _, _ -> mapOf("status" to "initiated") } + assertTrue(repo.connect(listOf("drive")) is AccountConnectorResult.Failure) + } + + private fun operationFixture() = + mapOf( + "op_id" to "op-a", + "seq" to 1, + "deadline_at" to 2000000000.0, + "settled" to false, + "targets" to + listOf( + mapOf( + "name" to "drive", + "kind" to "connector", + "action" to "authorize", + "state" to "initiated", + "connect_url" to "https://example.com/authorize", + ), + ), + ) + + private fun policyFixture() = + mapOf( + "effective" to + mapOf( + "mode" to "deny", + "revision" to "effective-revision", + "disabled_connectors" to emptyList(), + ), + "layers" to + listOf( + mapOf( + "kind" to "member", + "revision" to "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "body" to mapOf("mode" to "deny", "tools" to mapOf("drive" to listOf("mine"))), + ), + mapOf( + "kind" to "org", + "revision" to "org-revision", + "body" to mapOf("mode" to "deny", "tags" to mapOf("disable" to listOf("destructive"))), + ), + ), + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt index e1c7771e..d34bc4e7 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.data.ws import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -63,6 +64,39 @@ class ConnectionOperationEventParserTest { assertEquals("", (event as WsEvent.Unknown).raw) } + @Test + fun connectionUpdate_bindsToSessionOwner() { + val event = + EventParser.parseParams( + mapOf( + "type" to "connection.update", + "payload" to + operationPayload(seq = 2L) + + mapOf("owner" to mapOf("type" to "session", "session_id" to "owner-session")), + ), + ) + + assertTrue(event is WsEvent.ConnectionUpdate) + assertEquals("owner-session", (event as WsEvent.ConnectionUpdate).snapshot.sessionId) + } + + @Test + fun connectionUpdate_accountOwnerNeverBindsToSession() { + val event = + EventParser.parseParams( + mapOf( + "type" to "connection.update", + "session_id" to "", + "payload" to + operationPayload(seq = 2L) + + mapOf("owner" to mapOf("type" to "account"), "session_id" to "stale-session"), + ), + ) + + assertTrue(event is WsEvent.ConnectionUpdate) + assertNull((event as WsEvent.ConnectionUpdate).snapshot.sessionId) + } + private fun operationPayload(seq: Long): Map = mapOf( "op_id" to "op-1", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt index 132ac68a..f5f4172f 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.ws +import com.m57.hermescontrol.data.model.CatalogScanStatus import com.m57.hermescontrol.data.model.ConnectionTargetAction import com.m57.hermescontrol.data.model.ConnectionTargetKind import com.m57.hermescontrol.data.model.ConnectionTargetState @@ -60,6 +61,73 @@ class ConnectionOperationParserTest { assertEquals(ConnectionTargetState.UNKNOWN, target.state) } + @Test + fun pluginAndSkillTargets_parseKindAndCatalogFacts() { + val payload = + completePayload() + + ( + "targets" to + listOf( + mapOf( + "name" to "linear", + "kind" to "plugin", + "action" to "install", + "state" to "pending", + "display" to " Linear ", + "description" to "Linear issues.", + "tier" to "official", + "repo" to "NousResearch/hermes-plugins", + "sha" to "0123456789abcdef0123456789abcdef01234567", + "subdir" to "linear", + "scan" to mapOf("status" to "warnings", "summary" to "1 advisory"), + "requirements" to listOf("Hermes >=0.21", "LINEAR_API_KEY environment variable"), + "platforms" to listOf("linux", ""), + "target_profile" to "work", + "required_env" to + listOf(mapOf("name" to "LINEAR_API_KEY", "required" to false, "secret" to true)), + ), + mapOf( + "name" to "owner/skills/review", + "kind" to "skill", + "action" to "install", + "state" to "connected", + "skill" to "review", + "scan" to mapOf("status" to "future-status"), + ), + ) + ) + + val (plugin, skill) = ConnectionOperationParser.parse(payload, "runtime-session")!!.targets + + assertEquals(ConnectionTargetKind.PLUGIN, plugin.kind) + val catalog = plugin.catalog!! + assertEquals("Linear", catalog.display) + assertEquals("official", catalog.tier) + assertEquals("NousResearch/hermes-plugins", catalog.repo) + assertEquals("0123456789abcdef0123456789abcdef01234567", catalog.sha) + assertEquals("linear", catalog.subdir) + assertEquals(CatalogScanStatus.WARNINGS, catalog.scan?.status) + assertEquals("1 advisory", catalog.scan?.summary) + assertEquals(2, catalog.requirements.size) + assertEquals(listOf("linux"), catalog.platforms) + assertEquals("work", catalog.targetProfile) + assertEquals(listOf("LINEAR_API_KEY"), plugin.requiredEnv.map { it.name }) + + assertEquals(ConnectionTargetKind.SKILL, skill.kind) + assertEquals("owner/skills/review", skill.catalog?.display) + assertEquals("default", skill.catalog?.targetProfile) + assertEquals("review", skill.catalog?.skill) + assertEquals(CatalogScanStatus.UNKNOWN, skill.catalog?.scan?.status) + assertNull(skill.catalog?.repo) + } + + @Test + fun nonCatalogTargets_carryNoCatalogFacts() { + val targets = ConnectionOperationParser.parse(completePayload(), "runtime-session")!!.targets + + assertTrue(targets.all { it.catalog == null }) + } + @Test fun malformedIdentityOrDeadline_isRejected() { assertNull(ConnectionOperationParser.parse(completePayload() - "op_id", "runtime-session")) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt index bb95cc84..958bff9a 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt @@ -44,7 +44,10 @@ class ConnectorRepositoryTest { val result = repo.listConnectors("sess-abc-123") assertTrue(result is ConnectorListResult.Success) assertEquals(WsMethods.CONNECTORS_LIST, recordedMethod) - assertEquals(mapOf("session_id" to "sess-abc-123"), recordedParams) + assertEquals( + mapOf("owner" to mapOf("type" to "session", "session_id" to "sess-abc-123")), + recordedParams, + ) // Verify no profile or singular slug mutations assertFalse(recordedParams?.containsKey("profile") == true) assertFalse(recordedParams?.containsKey("connector") == true) @@ -80,9 +83,14 @@ class ConnectorRepositoryTest { assertTrue(result is ConnectorConnectResult.Success) assertEquals(WsMethods.CONNECTORS_CONNECT, recordedMethod) - assertEquals("sess-456", recordedParams?.get("session_id")) - assertEquals(listOf("linear", "github_app-1"), recordedParams?.get("connectors")) - assertEquals(true, recordedParams?.get("reconnect")) + assertEquals( + mapOf( + "owner" to mapOf("type" to "session", "session_id" to "sess-456"), + "connectors" to listOf("linear", "github_app-1"), + "reconnect" to true, + ), + recordedParams, + ) // Verify no singular slug key mutation assertFalse(recordedParams?.containsKey("connector") == true) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageModelsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageModelsTest.kt new file mode 100644 index 00000000..0fb5cc06 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageModelsTest.kt @@ -0,0 +1,376 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class PluginManageModelsTest { + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes and encodes string type correctly`() { + val jsonString = + """ + { + "key": "api_host", + "type": "string", + "label": "API Host", + "description": "Hostname for API", + "required": true, + "value": "https://api.example.com", + "default": "https://localhost", + "choices": null, + "env": "API_HOST", + "has_value": true + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("api_host", field.key) + assertEquals(PluginSettingFieldType.STRING, field.type) + assertEquals("API Host", field.label) + assertEquals("Hostname for API", field.description) + assertTrue(field.required) + assertEquals(JsonPrimitive("https://api.example.com"), field.value) + assertEquals(JsonPrimitive("https://localhost"), field.default) + assertNull(field.choices) + assertEquals("API_HOST", field.env) + assertEquals(true, field.hasValue) + + val encoded = OkHttpProvider.json.encodeToString(field) + val roundTripped = OkHttpProvider.json.decodeFromString(encoded) + assertEquals(field, roundTripped) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes and encodes number type with numeric value and default`() { + val jsonString = + """ + { + "key": "port", + "type": "number", + "label": "Port Number", + "value": 8080, + "default": 3000 + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("port", field.key) + assertEquals(PluginSettingFieldType.NUMBER, field.type) + assertEquals(JsonPrimitive(8080), field.value) + assertEquals(JsonPrimitive(3000), field.default) + assertEquals("", field.description) + assertFalse(field.required) + assertNull(field.choices) + assertNull(field.env) + assertNull(field.hasValue) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes and encodes boolean type with default false`() { + val jsonString = + """ + { + "key": "enabled", + "type": "boolean", + "label": "Enabled", + "value": true, + "default": false + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("enabled", field.key) + assertEquals(PluginSettingFieldType.BOOLEAN, field.type) + assertEquals(JsonPrimitive(true), field.value) + assertEquals(JsonPrimitive(false), field.default) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes enum type with choices`() { + val jsonString = + """ + { + "key": "mode", + "type": "enum", + "label": "Execution Mode", + "choices": ["fast", "balanced", "thorough"], + "value": "fast", + "default": "balanced" + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("mode", field.key) + assertEquals(PluginSettingFieldType.ENUM, field.type) + assertEquals(listOf("fast", "balanced", "thorough"), field.choices) + assertEquals(JsonPrimitive("fast"), field.value) + assertEquals(JsonPrimitive("balanced"), field.default) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes secret type with masked or null value and has_value flag`() { + val jsonString = + """ + { + "key": "api_token", + "type": "secret", + "label": "API Token", + "value": null, + "env": "SECRET_TOKEN", + "has_value": true + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("api_token", field.key) + assertEquals(PluginSettingFieldType.SECRET, field.type) + assertEquals(null, field.value) + assertEquals("SECRET_TOKEN", field.env) + assertEquals(true, field.hasValue) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types. + @Test + fun `PluginSettingField decodes json type with object or array values`() { + val jsonString = + """ + { + "key": "extra_config", + "type": "json", + "label": "Extra Config", + "value": {"timeout": 30, "retries": 3}, + "default": {} + } + """.trimIndent() + + val field = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("extra_config", field.key) + assertEquals(PluginSettingFieldType.JSON, field.type) + assertTrue(field.value is JsonObject) + val valueObj = field.value as JsonObject + assertEquals(JsonPrimitive(30), valueObj["timeout"]) + assertEquals(JsonPrimitive(3), valueObj["retries"]) + assertTrue(field.default is JsonObject) + } + + // CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types (error case). + @Test + fun `PluginSettingField decoding fails on unknown type value`() { + val jsonString = + """ + { + "key": "invalid_field", + "type": "unknown_future_type", + "label": "Invalid" + } + """.trimIndent() + + try { + OkHttpProvider.json.decodeFromString(jsonString) + fail("Expected SerializationException for unknown type") + } catch (_: SerializationException) { + // expected + } + } + + // CONTRACT: PluginServerState (CONNECTED, APP_NOT_RUNNING, ENDPOINT_UNAVAILABLE, VERSION_TOO_OLD, MISSING_APP, UNKNOWN). + @Test + fun `PluginServerState decodes all enum variants from snake_case strings`() { + val expectedStates = + mapOf( + "connected" to PluginServerState.CONNECTED, + "app_not_running" to PluginServerState.APP_NOT_RUNNING, + "endpoint_unavailable" to PluginServerState.ENDPOINT_UNAVAILABLE, + "no_interactive_session" to PluginServerState.NO_INTERACTIVE_SESSION, + "version_too_old" to PluginServerState.VERSION_TOO_OLD, + "missing_app" to PluginServerState.MISSING_APP, + "unknown" to PluginServerState.UNKNOWN, + ) + + for ((serialized, expectedEnum) in expectedStates) { + val decoded = OkHttpProvider.json.decodeFromString("\"$serialized\"") + assertEquals("Mismatch for $serialized", expectedEnum, decoded) + val reEncoded = OkHttpProvider.json.encodeToString(expectedEnum) + assertEquals("\"$serialized\"", reEncoded) + } + } + + // CONTRACT: PluginServerState (CONNECTED, APP_NOT_RUNNING, ENDPOINT_UNAVAILABLE, VERSION_TOO_OLD, MISSING_APP, UNKNOWN) via PluginServerRow. + @Test + fun `PluginServerRow decodes name state and sentence correctly`() { + val jsonString = + """ + { + "name": "camofox_service", + "state": "app_not_running", + "sentence": "Camofox browser is not running on port 9333." + } + """.trimIndent() + + val row = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("camofox_service", row.name) + assertEquals(PluginServerState.APP_NOT_RUNNING, row.state) + assertEquals("Camofox browser is not running on port 9333.", row.sentence) + } + + // CONTRACT: AgentPluginRow decoding from backend plugins.manage list payload shape. + @Test + fun `AgentPluginRow decodes full payload shape with servers and settings schema`() { + val jsonString = + """ + { + "name": "camofox", + "key": "camofox-browser", + "version": "1.2.0", + "description": "Anti-detect browser automation", + "source": "installed", + "status": "active", + "portable": true, + "install_dir": "/home/user/.hermes/plugins/camofox", + "has_desktop_half": true, + "servers": [ + { + "name": "cdp", + "state": "connected", + "sentence": "CDP connected" + } + ], + "settings_schema": [ + { + "key": "port", + "type": "number", + "label": "CDP Port", + "value": 9222 + } + ], + "catalog_name": "camofox", + "catalog_tier": "community", + "installed_sha": "abc1234", + "catalog_sha": "def5678", + "catalog_version": "1.3.0", + "update_available": true, + "pinned_sha": "abc1234" + } + """.trimIndent() + + val row = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("camofox", row.name) + assertEquals("camofox-browser", row.key) + assertEquals("1.2.0", row.version) + assertEquals("Anti-detect browser automation", row.description) + assertEquals("installed", row.source) + assertEquals("status" to "active", "status" to row.status) + assertTrue(row.portable) + assertEquals("/home/user/.hermes/plugins/camofox", row.installDir) + assertTrue(row.hasDesktopHalf) + assertEquals(1, row.servers.size) + assertEquals("cdp", row.servers[0].name) + assertEquals(PluginServerState.CONNECTED, row.servers[0].state) + assertEquals(1, row.settingsSchema?.size) + assertEquals("port", row.settingsSchema?.get(0)?.key) + assertEquals(PluginSettingFieldType.NUMBER, row.settingsSchema?.get(0)?.type) + assertEquals("camofox", row.catalogName) + assertEquals("community", row.catalogTier) + assertEquals("abc1234", row.installedSha) + assertEquals("def5678", row.catalogSha) + assertEquals("1.3.0", row.catalogVersion) + assertEquals(true, row.updateAvailable) + assertEquals("abc1234", row.pinnedSha) + } + + // CONTRACT: AgentPluginRow decoding from backend plugins.manage list payload shape (defaults/nulls). + @Test + fun `AgentPluginRow decodes minimal required payload using defaults`() { + val jsonString = + """ + { + "name": "minimal_plugin" + } + """.trimIndent() + + val row = OkHttpProvider.json.decodeFromString(jsonString) + assertEquals("minimal_plugin", row.name) + assertNull(row.key) + assertEquals("", row.version) + assertEquals("", row.description) + assertEquals("", row.source) + assertEquals("", row.status) + assertFalse(row.portable) + assertEquals("", row.installDir) + assertFalse(row.hasDesktopHalf) + assertTrue(row.servers.isEmpty()) + assertNull(row.settingsSchema) + assertNull(row.catalogName) + assertNull(row.catalogTier) + assertNull(row.installedSha) + assertNull(row.catalogSha) + assertNull(row.catalogVersion) + assertNull(row.updateAvailable) + assertNull(row.pinnedSha) + } + + // CONTRACT: PluginsManageSettingsResult decoding happy path and error cases. + @Test + fun `PluginsManageSettingsResult decodes success with updated plugin and written keys`() { + val jsonString = + """ + { + "ok": true, + "name": "camofox", + "written": ["port", "headless"], + "plugin": { + "name": "camofox", + "version": "1.2.0" + }, + "error": null + } + """.trimIndent() + + val result = OkHttpProvider.json.decodeFromString(jsonString) + assertTrue(result.ok) + assertEquals("camofox", result.name) + assertEquals(listOf("port", "headless"), result.written) + assertNotNull(result.plugin) + assertEquals("camofox", result.plugin?.name) + assertNull(result.error) + } + + // CONTRACT: PluginsManageSettingsResult decoding happy path and error cases. + @Test + fun `PluginsManageSettingsResult decodes failure result with error message`() { + val jsonString = + """ + { + "ok": false, + "name": "camofox", + "error": "Validation failed: port must be an integer" + } + """.trimIndent() + + val result = OkHttpProvider.json.decodeFromString(jsonString) + assertFalse(result.ok) + assertEquals("camofox", result.name) + assertEquals("Validation failed: port must be an integer", result.error) + assertNull(result.written) + assertNull(result.plugin) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt new file mode 100644 index 00000000..e861aa1a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt @@ -0,0 +1,236 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.encodeToJsonElement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class PluginManageRepositoryTest { + // CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list. + @Test + fun `listPlugins without profile calls PLUGINS_MANAGE action list and decodes plugins list`() = + runTest { + var capturedMethod = "" + var capturedParams: Map = emptyMap() + + val mockPayload = + OkHttpProvider.json.parseToJsonElement( + """ + { + "plugins": [ + { + "name": "plugin-alpha", + "version": "1.0.0", + "status": "active" + }, + { + "name": "plugin-beta", + "version": "2.1.0", + "status": "disabled" + } + ] + } + """.trimIndent(), + ) + + val repository = + PluginManageRepository { method, params -> + capturedMethod = method + capturedParams = params + mockPayload + } + + val plugins = repository.listPlugins() + + assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) + assertEquals(mapOf("action" to "list"), capturedParams) + assertEquals(2, plugins.size) + assertEquals("plugin-alpha", plugins[0].name) + assertEquals("1.0.0", plugins[0].version) + assertEquals("active", plugins[0].status) + assertEquals("plugin-beta", plugins[1].name) + assertEquals("2.1.0", plugins[1].version) + assertEquals("disabled", plugins[1].status) + } + + // CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list. + @Test + fun `listPlugins with non-blank profile includes profile parameter in request`() = + runTest { + var capturedMethod = "" + var capturedParams: Map = emptyMap() + + val mockPayload = + OkHttpProvider.json.parseToJsonElement( + """ + { + "plugins": [] + } + """.trimIndent(), + ) + + val repository = + PluginManageRepository { method, params -> + capturedMethod = method + capturedParams = params + mockPayload + } + + val plugins = repository.listPlugins(profile = "work") + + assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) + assertEquals(mapOf("action" to "list", "profile" to "work"), capturedParams) + assertTrue(plugins.isEmpty()) + } + + // CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list. + @Test + fun `listPlugins returns empty list when plugins key is missing or response is not a JsonObject`() = + runTest { + val emptyObjRepo = + PluginManageRepository { _, _ -> + OkHttpProvider.json.parseToJsonElement("{}") + } + assertTrue(emptyObjRepo.listPlugins().isEmpty()) + + val nonObjRepo = + PluginManageRepository { _, _ -> + OkHttpProvider.json.parseToJsonElement("[]") + } + assertTrue(nonObjRepo.listPlugins().isEmpty()) + + val nullRepo = + PluginManageRepository { _, _ -> + null + } + assertTrue(nullRepo.listPlugins().isEmpty()) + } + + // CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result. + @Test + fun `saveSettings without profile calls PLUGINS_MANAGE with action settings key and JsonObject values`() = + runTest { + var capturedMethod = "" + var capturedParams: Map = emptyMap() + + val mockResponse = + OkHttpProvider.json.parseToJsonElement( + """ + { + "ok": true, + "name": "camofox", + "written": ["port", "headless"], + "plugin": { + "name": "camofox", + "version": "1.0.0" + } + } + """.trimIndent(), + ) + + val repository = + PluginManageRepository { method, params -> + capturedMethod = method + capturedParams = params + mockResponse + } + + val valuesToSave: Map = + mapOf( + "port" to JsonPrimitive(9222), + "headless" to JsonPrimitive(true), + ) + + val result = repository.saveSettings(key = "camofox", values = valuesToSave) + + assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) + assertEquals("settings", capturedParams["action"]) + assertEquals("camofox", capturedParams["key"]) + assertEquals(JsonObject(valuesToSave), capturedParams["values"]) + assertFalse(capturedParams.containsKey("profile")) + + assertTrue(result.ok) + assertEquals("camofox", result.name) + assertEquals(listOf("port", "headless"), result.written) + assertNotNull(result.plugin) + assertEquals("camofox", result.plugin?.name) + assertNull(result.error) + } + + // CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result. + @Test + fun `saveSettings with profile includes profile in request and handles error result`() = + runTest { + var capturedMethod = "" + var capturedParams: Map = emptyMap() + + val mockResponse = + OkHttpProvider.json.parseToJsonElement( + """ + { + "ok": false, + "name": "camofox", + "error": "Port already in use" + } + """.trimIndent(), + ) + + val repository = + PluginManageRepository { method, params -> + capturedMethod = method + capturedParams = params + mockResponse + } + + val valuesToSave: Map = + mapOf( + "port" to JsonPrimitive(8080), + ) + + val result = repository.saveSettings(key = "camofox", values = valuesToSave, profile = "staging") + + assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) + assertEquals( + mapOf( + "action" to "settings", + "key" to "camofox", + "values" to JsonObject(valuesToSave), + "profile" to "staging", + ), + capturedParams, + ) + + assertFalse(result.ok) + assertEquals("camofox", result.name) + assertEquals("Port already in use", result.error) + assertNull(result.written) + assertNull(result.plugin) + } + + // CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result (error case). + @Test + fun `saveSettings throws SerializationException when response is malformed`() = + runTest { + val repository = + PluginManageRepository { _, _ -> + OkHttpProvider.json.parseToJsonElement("{\"ok\": \"not-a-boolean\"}") + } + + try { + repository.saveSettings("camofox", emptyMap()) + fail("Expected SerializationException when response JSON is malformed") + } catch (_: SerializationException) { + // Expected + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt index a1f7cd81..7a82e647 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/WsProfileParamsTest.kt @@ -289,7 +289,7 @@ class WsProfileParamsTest { * home) without being listed here, this test fails — the set cannot drift. * * Methods that operate on an ALREADY-BOUND session (session.usage, - * session.interrupt, session.branch, session.redirect, + * session.interrupt, session.branch, session.branch_whole, session.redirect, * session.context_breakdown, prompt.submit, slash/command dispatch, * approval/clarify/secret/sudo responses, file attach, process.*, * subscription.*, commands_catalog, usage.bars) resolve via the diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt new file mode 100644 index 00000000..daed040b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt @@ -0,0 +1,533 @@ +package com.m57.hermescontrol.ui.chat + +import android.app.Application +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope +import com.m57.hermescontrol.data.local.HermesDatabase +import com.m57.hermescontrol.data.model.CompressionSummary +import com.m57.hermescontrol.data.model.SessionCompressResponse +import com.m57.hermescontrol.data.model.SessionMessage +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator +import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository +import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatCompressRpcTest { + private val testDispatcher = StandardTestDispatcher() + private val mockEventsFlow = MutableSharedFlow(extraBufferCapacity = 64) + private val mockConnectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED) + private lateinit var app: Application + private lateinit var fakeRepo: FakeChatPersistenceRepository + private var reqCount = 0 + private var sessionCreateRequestId: String? = null + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + reqCount = 0 + sessionCreateRequestId = null + + mockkStatic(android.util.Log::class) + every { android.util.Log.d(any(), any()) } returns 0 + every { android.util.Log.i(any(), any()) } returns 0 + every { android.util.Log.w(any(), any()) } returns 0 + every { android.util.Log.e(any(), any(), any()) } returns 0 + + mockkObject(AuthManager) + every { AuthManager.getPinnedModels() } returns emptyList() + every { AuthManager.dataScopeFlow } returns MutableStateFlow(null) + every { AuthManager.activeProfileId } returns MutableStateFlow(null) + every { AuthManager.currentDataScope() } returns null + every { AuthManager.getToken() } returns "test-token" + every { AuthManager.getBaseUrl() } returns "http://test.local/" + every { AuthManager.getSelectedProfileId() } returns null + every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT + every { AuthManager.isTypingEffectEnabled() } returns true + every { AuthManager.getTypingEffectDelayMs() } returns 30 + every { AuthManager.isMessageStatsEnabled() } returns false + every { AuthManager.isUserMessageTokensEnabled() } returns true + every { AuthManager.isAssistantMessageTokensEnabled() } returns true + every { AuthManager.isTokensPerSecondEnabled() } returns true + every { AuthManager.isModelProviderShown() } returns false + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.isRestoreLastSession() } returns false + every { AuthManager.getLastOpenedSessionId() } returns null + every { AuthManager.setLastOpenedSessionId(any()) } returns Unit + every { AuthManager.clearLastOpenedSessionId() } returns Unit + + mockkObject(HermesWsClient) + every { HermesWsClient.events } returns mockEventsFlow + every { HermesWsClient.connectionStatus } returns mockConnectionStatus + every { HermesWsClient.connect() } answers { + mockConnectionStatus.value = ConnectionStatus.CONNECTING + } + every { HermesWsClient.disconnect() } returns Unit + + every { HermesWsClient.send(any(), any(), any()) } answers { + reqCount++ + val id = "req-id-$reqCount" + if (arg(0) == WsMethods.SESSION_CREATE) { + sessionCreateRequestId = id + } + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + reqCount++ + val id = "req-msg-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + every { HermesWsClient.request(any(), any(), any()) } answers { + HermesWsClient.send(arg(0), arg(1)) {} + CompletableDeferred(Unit) + } + + mockkObject(ApiClient) + coEvery { ApiClient.hermesApi.getModelInfo() } returns retrofit2.Response.success(mockk(relaxed = true)) + coEvery { ApiClient.hermesApi.getProfiles() } returns + retrofit2.Response.success( + com.m57.hermescontrol.data.model + .ProfilesResponse(emptyList()), + ) + + mockkObject(HermesDatabase) + mockkObject(ProfileSwitchCoordinator) + every { ProfileSwitchCoordinator.switched } returns MutableSharedFlow() + every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow() + + app = mockk(relaxed = true) + fakeRepo = FakeChatPersistenceRepository() + mockConnectionStatus.value = ConnectionStatus.DISCONNECTED + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + private suspend fun TestScope.createViewModelWithSession(): Pair { + val vm = + ChatViewModel( + app, + startCleanup = false, + repo = fakeRepo, + slashUsageStore = FakeSlashUsageStore(), + searchDispatcher = testDispatcher, + ioDispatcher = testDispatcher, + historyDispatcher = testDispatcher, + sendStore = ChatSendStore(), + ) + advanceUntilIdle() + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + val createRequestId = requireNotNull(sessionCreateRequestId) { "session.create was not sent" } + mockEventsFlow.emit(WsEvent.RpcResult(createRequestId, mapOf("session_id" to "session-xyz"))) + advanceUntilIdle() + return Pair(vm, "session-xyz") + } + + @Test + fun `compress calls SESSION_COMPRESS not command dispatch or slash exec`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val methodCalls = mutableListOf() + every { + HermesWsClient.request(capture(methodCalls), any(), any()) + } answers { + CompletableDeferred(null) + } + + vm.sendMessage("/compress") + advanceUntilIdle() + + assertTrue("expected SESSION_COMPRESS in $methodCalls", WsMethods.SESSION_COMPRESS in methodCalls) + assertTrue("expected NO command.dispatch", WsMethods.COMMAND_DISPATCH !in methodCalls) + assertTrue("expected NO slash.exec", "slash.exec" !in methodCalls) + } + + @Test + fun `compress request parameters contain session_id and omit focus_topic when blank`() = + runTest { + val (vm, sessionId) = createViewModelWithSession() + + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + CompletableDeferred(null) + } + + vm.sendMessage("/compress") + advanceUntilIdle() + + val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) + assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) + val params = paramsCalls[compressIndex] + assertEquals(sessionId, params["session_id"]) + assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic")) + } + + @Test + fun `compress request parameters forward focus_topic when non-blank`() = + runTest { + val (vm, sessionId) = createViewModelWithSession() + + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + CompletableDeferred(null) + } + + vm.sendMessage("/compress auth decisions") + advanceUntilIdle() + + val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) + assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) + val params = paramsCalls[compressIndex] + assertEquals(sessionId, params["session_id"]) + assertEquals("auth decisions", params["focus_topic"]) + } + + @Test + fun `compact behaves identically to compress omitting focus_topic when blank`() = + runTest { + val (vm, sessionId) = createViewModelWithSession() + + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + CompletableDeferred(null) + } + + vm.sendMessage("/compact") + advanceUntilIdle() + + val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) + assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) + val params = paramsCalls[compressIndex] + assertEquals(sessionId, params["session_id"]) + assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic")) + assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls) + } + + @Test + fun `compact forwards focus_topic when non-blank`() = + runTest { + val (vm, sessionId) = createViewModelWithSession() + + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() + every { + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + } answers { + CompletableDeferred(null) + } + + vm.sendMessage("/compact architecture details") + advanceUntilIdle() + + val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) + assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) + val params = paramsCalls[compressIndex] + assertEquals(sessionId, params["session_id"]) + assertEquals("architecture details", params["focus_topic"]) + } + + @Test + fun `final response summary headline token_line note added to messages`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val responseJson = + buildJsonObject { + put("status", JsonPrimitive("ok")) + put( + "summary", + buildJsonObject { + put("headline", JsonPrimitive("Context compressed")) + put("token_line", JsonPrimitive("Tokens: 120k -> 35k (70% saved)")) + put("note", JsonPrimitive("Trimmed 14 earlier turns")) + }, + ) + } + + every { + HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any()) + } answers { + CompletableDeferred(responseJson) + } + + vm.sendMessage("/compress") + advanceUntilIdle() + + val lastAssistantMessage = + vm.uiState.value.messages + .lastOrNull { it.role == MessageRole.ASSISTANT } + val expectedContent = "Context compressed\nTokens: 120k -> 35k (70% saved)\nTrimmed 14 earlier turns" + assertEquals(expectedContent, lastAssistantMessage?.content) + } + + @Test + fun `returned messages replace and reconcile visible live transcript`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val serverMessages = + listOf( + SessionMessage( + id = 101, + role = "user", + content = JsonPrimitive("Original prompt"), + ), + SessionMessage( + id = 102, + role = "assistant", + content = JsonPrimitive("Compressed response"), + ), + ) + + val responseJson = + buildJsonObject { + put("status", JsonPrimitive("ok")) + put( + "summary", + buildJsonObject { + put("headline", JsonPrimitive("Summary headline")) + }, + ) + put( + "messages", + buildJsonArray { + add( + buildJsonObject { + put("id", JsonPrimitive(101)) + put("role", JsonPrimitive("user")) + put("content", JsonPrimitive("Original prompt")) + }, + ) + add( + buildJsonObject { + put("id", JsonPrimitive(102)) + put("role", JsonPrimitive("assistant")) + put("content", JsonPrimitive("Compressed response")) + }, + ) + }, + ) + } + + every { + HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any()) + } answers { + CompletableDeferred(responseJson) + } + + vm.sendMessage("/compress") + advanceUntilIdle() + + val messages = vm.uiState.value.messages + // The transcript contains serverMessages, plus the assistant summary feedback + val userMsg = messages.firstOrNull { it.role == MessageRole.USER && it.content == "Original prompt" } + assertEquals("Original prompt", userMsg?.content) + val serverAssistantMsg = + messages.firstOrNull { + it.role == MessageRole.ASSISTANT && it.content == "Compressed response" + } + assertTrue("expected replacement assistant message", serverAssistantMsg != null) + val summaryMsg = messages.lastOrNull { it.role == MessageRole.ASSISTANT } + assertEquals("Summary headline", summaryMsg?.content) + } + + @Test + fun `fetchContextUsage is triggered after successful compression`() = + runTest { + val (vm, sessionId) = createViewModelWithSession() + + val methodCalls = mutableListOf() + every { + HermesWsClient.request(capture(methodCalls), any(), any()) + } answers { + val method = arg(0) + if (method == WsMethods.SESSION_COMPRESS) { + val responseJson = + buildJsonObject { + put("status", JsonPrimitive("ok")) + put("message", JsonPrimitive("Context compressed.")) + } + CompletableDeferred(responseJson) + } else { + CompletableDeferred(Unit) + } + } + + methodCalls.clear() + + vm.sendMessage("/compress") + advanceUntilIdle() + + // fetchContextUsage calls SESSION_CONTEXT_BREAKDOWN and SESSION_USAGE with sessionId + assertTrue( + "expected SESSION_CONTEXT_BREAKDOWN in $methodCalls", + WsMethods.SESSION_CONTEXT_BREAKDOWN in methodCalls, + ) + assertTrue( + "expected SESSION_USAGE in $methodCalls", + WsMethods.SESSION_USAGE in methodCalls, + ) + } + + @Test + fun `isCompressing is reset to false on success`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val deferred = CompletableDeferred() + every { + HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any()) + } returns deferred + + vm.sendMessage("/compress") + // Progress before completion + testDispatcher.scheduler.runCurrent() + assertTrue("isCompressing should be true while in flight", vm.uiState.value.isCompressing) + assertEquals("⏳ Compressing context...", vm.uiState.value.compressionStatus) + + deferred.complete( + buildJsonObject { + put("status", JsonPrimitive("ok")) + }, + ) + advanceUntilIdle() + + assertFalse("isCompressing should be reset to false", vm.uiState.value.isCompressing) + assertEquals(null, vm.uiState.value.compressionStatus) + } + + @Test + fun `isCompressing is reset to false on RPC error`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val deferred = CompletableDeferred() + every { + HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any()) + } returns deferred + + vm.sendMessage("/compress") + testDispatcher.scheduler.runCurrent() + assertTrue(vm.uiState.value.isCompressing) + + deferred.completeExceptionally( + HermesWsClient.HermesRpcException("compression failed on backend", 4018), + ) + advanceUntilIdle() + + assertFalse("isCompressing should be false on RPC error", vm.uiState.value.isCompressing) + assertEquals(null, vm.uiState.value.compressionStatus) + val errorMsg = + vm.uiState.value.messages + .lastOrNull { it.role == MessageRole.ASSISTANT } + assertTrue(errorMsg?.content?.contains("compression failed on backend") == true) + } + + @Test + fun `isCompressing is reset to false on generic exception`() = + runTest { + val (vm, _) = createViewModelWithSession() + + val deferred = CompletableDeferred() + every { + HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any()) + } returns deferred + + vm.sendMessage("/compress") + testDispatcher.scheduler.runCurrent() + assertTrue(vm.uiState.value.isCompressing) + + deferred.completeExceptionally(RuntimeException("network disconnected")) + advanceUntilIdle() + + assertFalse("isCompressing should be false on generic exception", vm.uiState.value.isCompressing) + assertEquals(null, vm.uiState.value.compressionStatus) + val errorMsg = + vm.uiState.value.messages + .lastOrNull { it.role == MessageRole.ASSISTANT } + assertTrue(errorMsg?.content?.contains("network disconnected") == true) + } + + @Test + fun `duplicate compress while isCompressing is true is a no-op`() = + runTest { + val (vm, _) = createViewModelWithSession() + + var compressCalls = 0 + val deferred = CompletableDeferred() + every { + HermesWsClient.request(WsMethods.SESSION_COMPRESS, any(), any()) + } answers { + compressCalls++ + deferred + } + + vm.sendMessage("/compress") + testDispatcher.scheduler.runCurrent() + assertTrue(vm.uiState.value.isCompressing) + assertEquals(1, compressCalls) + + // Second invocation while in flight + vm.sendMessage("/compress") + testDispatcher.scheduler.runCurrent() + assertEquals("duplicate call should not trigger another RPC", 1, compressCalls) + + // Also check /compact is blocked while in flight + vm.sendMessage("/compact") + testDispatcher.scheduler.runCurrent() + assertEquals("compact while compressing should not trigger another RPC", 1, compressCalls) + + deferred.complete(null) + advanceUntilIdle() + assertFalse(vm.uiState.value.isCompressing) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt index 107e8086..2a4b1315 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt @@ -13,6 +13,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -109,7 +111,8 @@ class ChatConnectionOperationDelegateTest { assertEquals(1, requester.calls.size) val call = requester.calls.single() assertEquals(WsMethods.CONNECTION_RESPOND, call.first) - assertEquals("session-a", call.second["session_id"]) + assertEquals(setOf("owner", "op_id", "result"), call.second.keys) + assertEquals(mapOf("type" to "session", "session_id" to "session-a"), call.second["owner"]) assertEquals("op-a", call.second["op_id"]) @Suppress("UNCHECKED_CAST") val result = call.second["result"] as Map @@ -177,11 +180,63 @@ class ChatConnectionOperationDelegateTest { continueDelegate.continueOperation() assertEquals( - mapOf("settled_by" to "continue"), - continueRequester.calls.single().second["result"], + mapOf( + "owner" to mapOf("type" to "session", "session_id" to "session-a"), + "op_id" to "op-a", + "result" to mapOf("settled_by" to "continue"), + ), + continueRequester.calls.single().second, ) } + @Test + fun wakePayload_sendsSessionOwnerNotTopLevelSessionId() = + runTest { + val requester = RecordingRequester() + val delegate = delegate(requester) + delegate.reset("session-a") + delegate.acceptRequest(snapshot()) + + delegate.wake("op-a") + + val call = requester.calls.single() + assertEquals(WsMethods.CONNECTORS_OPERATION_WAKE, call.first) + assertEquals( + mapOf( + "owner" to mapOf("type" to "session", "session_id" to "session-a"), + "op_id" to "op-a", + ), + call.second, + ) + } + + @Test + fun ownershipAndRuntimeFailures_mapToTypedNonRetryableErrors() = + runTest { + val cases = + listOf( + HermesWsClient.HermesRpcException("x", code = 4001) to + ChatConnectionOperationDelegate.NOT_OWNER, + HermesWsClient.HermesRpcException( + "x", + code = 5033, + data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))), + ) to ChatConnectionOperationDelegate.UNSUPPORTED_RUNTIME, + ) + for ((failure, expected) in cases) { + val delegate = delegate(RecordingRequester(failure = failure)) + delegate.reset("session-a") + delegate.acceptRequest(snapshot()) + + delegate.continueOperation() + + val error = delegate.state.value.error + assertEquals(expected, error?.message) + assertFalse(error?.retryable ?: true) + assertNull(delegate.state.value.pendingAction) + } + } + @Test fun rpcFailureUnlocksWithSanitizedError() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatHistoryCommentaryTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatHistoryCommentaryTest.kt new file mode 100644 index 00000000..c51fcc5c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatHistoryCommentaryTest.kt @@ -0,0 +1,118 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.SessionMessage +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** Issue #1284: history rows carry display_commentary / display_reasoning projections. */ +class ChatHistoryCommentaryTest { + private fun row( + content: String, + reasoning: String? = null, + commentary: List? = null, + displayReasoning: String? = null, + id: Int = 7, + ) = SessionMessage( + id = id, + role = "assistant", + content = JsonPrimitive(content), + reasoning = reasoning?.let { JsonPrimitive(it) }, + display_commentary = commentary?.let { list -> JsonArray(list.map { JsonPrimitive(it) }) }, + display_reasoning = displayReasoning?.let { JsonPrimitive(it) as JsonElement }, + ) + + private fun map(vararg rows: SessionMessage) = mapServerMessages("s", rows.toList(), 0, true, emptyList()) + + private fun countOf( + haystack: String, + needle: String, + ) = haystack.windowed(needle.length).count { it == needle } + + @Test + fun commentaryRendersOnceBeforeReplyAndLeavesReasoning() { + val commentary = "I will inspect the files." + val message = + map( + row( + content = "Done: two files changed.", + reasoning = "Private plan.\n\n$commentary", + commentary = listOf(commentary), + displayReasoning = "Private plan.", + ), + ).single() + + assertEquals("$commentary\n\nDone: two files changed.", message.content) + assertEquals("Private plan.", message.reasoningText) + assertEquals(1, countOf(message.content + message.reasoningText, commentary)) + } + + @Test + fun emptyDisplayReasoningWinsOverRawReasoning() { + val commentary = "Checking." + val message = + map(row(content = "", reasoning = commentary, commentary = listOf(commentary), displayReasoning = "")) + .single() + + assertEquals(commentary, message.content) + assertEquals("", message.reasoningText) + } + + @Test + fun commentaryEqualToReplyIsNotDuplicated() { + val text = "Looking at it now." + val message = map(row(content = text, commentary = listOf(text), displayReasoning = "")).single() + + assertEquals(text, message.content) + } + + @Test + fun disabledCommentaryKeepsItOutOfTranscriptAndReasoning() { + // Backend policy off: empty list, display_reasoning still strips the raw copy. + val message = + map( + row( + content = "Answer", + reasoning = "Plan.\nSecret interim", + commentary = emptyList(), + displayReasoning = "Plan.", + ), + ).single() + + assertEquals("Answer", message.content) + assertEquals("Plan.", message.reasoningText) + assertFalse(message.content.contains("Secret interim")) + } + + @Test + fun olderGatewayFallsBackToRawReasoning() { + val message = map(row(content = "Answer", reasoning = "Raw trace")).single() + + assertEquals("Answer", message.content) + assertEquals("Raw trace", message.reasoningText) + } + + @Test + fun liveSealedCommentaryBubbleIsCoveredByRestRowNotDuplicated() { + val commentary = "I will inspect the files." + val liveOrphan = ChatMessage(id = "live-orphan", role = MessageRole.ASSISTANT, content = commentary) + val rest = + map(row(content = "", reasoning = commentary, commentary = listOf(commentary), displayReasoning = "")) + + val merged = mergeTranscriptWithLive(rest, listOf(liveOrphan), preserveLiveIds = true) + + assertEquals(1, merged.count { it.content.contains(commentary) }) + assertEquals("", merged.single().reasoningText) + } + + @Test + fun multipleCommentaryItemsKeepOrderAndSkipBlanks() { + val message = + map(row(content = "Final", commentary = listOf("First.", " ", "Second."), displayReasoning = "")).single() + + assertEquals("First.\n\nSecond.\n\nFinal", message.content) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt index 815a315f..69b18af1 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt @@ -316,7 +316,7 @@ class ChatPagingMergeTest { val mapped = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(live, echo)) - assertEquals(listOf(live.copy(restId = "rest-s-42")), mapped) + assertEquals(listOf(live.copy(restId = "rest-s-42", serverRowId = 42L)), mapped) } @Test @@ -328,7 +328,10 @@ class ChatPagingMergeTest { val mapped = mapServerMessages("s", listOf(serverTool(42), serverTool(43)), 0, true, cache) assertEquals( - listOf(first.copy(restId = "rest-s-42"), second.copy(restId = "rest-s-43")), + listOf( + first.copy(restId = "rest-s-42", serverRowId = 42L), + second.copy(restId = "rest-s-43", serverRowId = 43L), + ), mapped, ) assertEquals(2, mapped.map { it.id }.toSet().size) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistedReceiptsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistedReceiptsTest.kt new file mode 100644 index 00000000..969222c0 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistedReceiptsTest.kt @@ -0,0 +1,179 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.ws.WsEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** #1285: persisted-turn receipts are addresses, never negative acknowledgements. */ +class ChatPersistedReceiptsTest { + @Test + fun parsesCompleteReceiptWithMixedNumberTypes() { + val turn = + parsePersistedTurn( + mapOf( + "persisted_turn" to + mapOf( + "row_ids" to listOf(41, 42L, 43.0), + "complete" to true, + "user_row_id" to 41, + "final_assistant_row_id" to 43L, + ), + ), + ) + + assertEquals(PersistedTurn(true, 41L, 43L), turn) + assertEquals( + 43L, + parsePersistedTurn(mapOf("persisted_turn" to mapOf("final_assistant_row_id" to 43.0)))?.finalAssistantRowId, + ) + } + + @Test + fun missingIdsStayUnprovenAndMalformedValuesAreIgnored() { + val turn = + parsePersistedTurn( + mapOf( + "persisted_turn" to + mapOf("complete" to "true", "user_row_id" to true, "final_assistant_row_id" to 1.5), + ), + )!! + + assertFalse(turn.complete) + assertNull(positiveRowId("8")) + assertNull(positiveRowId(0)) + assertNull(positiveRowId(-1L)) + assertNull(turn.userRowId) + assertNull(turn.finalAssistantRowId) + assertNull(parsePersistedTurn(mapOf("text" to "done"))) + assertNull(parsePersistedTurn(null)) + } + + @Test + fun rowIdMatchesUserEchoEvenWhenServerTextDiffers() { + val local = ChatMessage(id = "local", role = MessageRole.USER, content = "hi", serverRowId = 42) + val rest = + ChatMessage(id = "rest-s-42", role = MessageRole.USER, content = "hi\n\n--- rewritten", serverRowId = 42) + + val merged = mergeTranscriptWithLive(listOf(rest), listOf(local), preserveLiveIds = true) + + assertEquals(listOf("local"), merged.map { it.id }) + assertEquals("rest-s-42", merged.single().restId) + assertEquals(42L, merged.single().serverRowId) + } + + @Test + fun differentRowIdsKeepRepeatedTextAsSeparateTurns() { + val local = ChatMessage(id = "local", role = MessageRole.USER, content = "again", serverRowId = 43) + val older = ChatMessage(id = "rest-s-42", role = MessageRole.USER, content = "again", serverRowId = 42) + + val merged = mergeTranscriptWithLive(listOf(older), listOf(local), preserveLiveIds = true) + + assertEquals(setOf("local", "rest-s-42"), merged.map { it.id }.toSet()) + } + + @Test + fun missingRowIdFallsBackToContentMatching() { + val local = ChatMessage(id = "local", role = MessageRole.USER, content = "hello") + val rest = ChatMessage(id = "rest-s-9", role = MessageRole.USER, content = "hello", serverRowId = 9) + + val merged = mergeTranscriptWithLive(listOf(rest), listOf(local), preserveLiveIds = true) + + assertEquals(listOf("local"), merged.map { it.id }) + assertEquals(9L, merged.single().serverRowId) + } + + @Test + fun completeReceiptBindsFinalAssistantRow() { + val result = + complete( + "answer", + mapOf( + "row_ids" to listOf(1, 2), + "complete" to true, + "final_assistant_row_id" to 2, + ), + ) + + assertEquals( + 2L, + result.state.messages + .single() + .serverRowId, + ) + } + + @Test + fun partialReceiptStillBindsProvenFinalRowButKeepsLocalReply() { + val result = + complete("answer", mapOf("row_ids" to listOf(2), "complete" to false, "final_assistant_row_id" to 2)) + + assertEquals( + "answer", + result.state.messages + .single() + .content, + ) + assertEquals( + 2L, + result.state.messages + .single() + .serverRowId, + ) + } + + @Test + fun receiptWithoutFinalIdLeavesReplyUnbound() { + val result = complete("answer", mapOf("row_ids" to listOf(1), "complete" to false, "user_row_id" to 1)) + + assertEquals( + "answer", + result.state.messages + .single() + .content, + ) + assertNull( + result.state.messages + .single() + .serverRowId, + ) + } + + @Test + fun strippedCommentaryPrefixDoesNotClaimFinalRow() { + val sealed = ChatMessage(id = "sealed", role = MessageRole.ASSISTANT, content = "Looking. ") + val result = + ChatWsEventReducer.reduce( + state = ChatUiState(currentSessionId = "session-1", messages = listOf(sealed)), + streamingState = StreamingState(sealedOrphanIds = listOf("sealed")), + event = + WsEvent.MessageComplete( + "Looking. Done", + "session-1", + rawPayload = + mapOf( + "persisted_turn" to + mapOf("row_ids" to listOf(5), "complete" to true, "final_assistant_row_id" to 5), + ), + ), + currentSessionId = "session-1", + ) + + val reply = result.state.messages.single { it.id != "sealed" } + assertEquals("Done", reply.content) + assertNull(reply.serverRowId) + assertTrue(result.state.messages.none { it.serverRowId == 5L }) + } + + private fun complete( + text: String, + receipt: Map, + ) = ChatWsEventReducer.reduce( + state = ChatUiState(currentSessionId = "session-1"), + streamingState = StreamingState(), + event = WsEvent.MessageComplete(text, "session-1", rawPayload = mapOf("persisted_turn" to receipt)), + currentSessionId = "session-1", + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt index 55f61e9d..412c20f7 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt @@ -64,6 +64,28 @@ class ChatPromptProtocolTest { assertEquals("new answer", answers?.get("q1")?.jsonPrimitive?.content) } + @Test + fun legacyClarifyBatchReplay_skipsLockedAnswers_withoutEmptyOverwrite() { + val responses = + buildLegacyClarifyBatchResponses( + questions = + listOf( + ClarifyQuestionUi(qid = "q0", question = "Already answered?"), + ClarifyQuestionUi(qid = "q1", question = "Still waiting?"), + ClarifyQuestionUi(qid = "q2", question = "Optional skip?"), + ), + answers = + mapOf( + "q0" to "", + "q1" to " new answer ", + "q2" to "", + ), + lockedAnswers = mapOf("q0" to "yes"), + ) + + assertEquals(listOf("q1" to "new answer", "q2" to ""), responses) + } + @Test fun credentialPrompt_serverRequestId_controls_response_and_cancellation() { val scope = TestScope(dispatcher) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt deleted file mode 100644 index e11e1dc4..00000000 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -import com.m57.hermescontrol.data.model.BusySendMode -import org.junit.Assert.assertEquals -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue -import org.junit.Test - -class ChatQueuePresentationTest { - private val message = ChatMessage("queued-id", MessageRole.USER, "Later") - - private fun pending(state: PendingSendState) = - PendingSend(message.id, "scope", "session", message.content, mode = BusySendMode.QUEUE, state = state) - - @Test - fun queuedAndParkedMessagesRemainInternalUntilDispatch() { - val messages = listOf(message) - for (state in listOf(PendingSendState.QUEUED, PendingSendState.PARKED)) { - val receipts = listOf(pending(state)) - assertTrue(messagesWithoutUnsentQueue(messages, receipts).isEmpty()) - assertEquals(listOf(message), messages) - assertEquals(state, receipts.single().state) - } - } - - @Test - fun queuedMessageAppearsOnceWhenSendingStarts() { - val messages = listOf(message) - assertTrue(messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))).isEmpty()) - for (state in listOf(PendingSendState.SENDING, PendingSendState.ACCEPTED)) { - assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(state)))) - } - } - - @Test - fun ordinarySendAndOtherMessagesAreUnchanged() { - val other = ChatMessage("other", MessageRole.USER, "Send now") - val messages = listOf(other, message) - assertSame(messages, messagesWithoutUnsentQueue(messages, emptyList())) - assertEquals(listOf(other), messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED)))) - } - - @Test - fun canonicalHistoryIsNeverHiddenByStaleQueueReceipt() { - val messages = listOf(message.copy(restId = "rest-session-12")) - assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED)))) - } -} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index c2a9f6f5..a943144a 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -761,12 +761,12 @@ class ChatViewModelTest { id } - // /fork with an optional branch title. + // /fork with an optional branch title sends session.branch_whole. viewModel.sendMessage("/fork my-fork") advanceUntilIdle() - val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH } - assertNotNull("session.branch should be dispatched for /fork", branchSent) + val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } + assertNotNull("session.branch_whole should be dispatched for /fork", branchSent) assertEquals(sessionId, branchSent!!.second["session_id"]) assertEquals("my-fork", branchSent.second["name"]) } @@ -811,12 +811,147 @@ class ChatViewModelTest { viewModel.sendMessage("/fork") advanceUntilIdle() - val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH } - assertNotNull("session.branch should be dispatched for /fork", branchSent) + val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } + assertNotNull("session.branch_whole should be dispatched for /fork", branchSent) assertEquals(sessionId, branchSent!!.second["session_id"]) assertFalse("name param should be omitted when no title given", branchSent.second.containsKey("name")) } + @Test + fun testSlashCommand_fork_branchWholeSuccess_handledLikeSessionBranch() = + runTest { + val (viewModel, _) = createViewModelWithSession() + val captured = mutableListOf>>() + every { HermesWsClient.send(any(), any(), any()) } answers { + val id = "req-${captured.size + 1}" + captured.add(arg(0) to (arg>(1))) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + viewModel.sendMessage("/fork") + advanceUntilIdle() + + val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } + assertNotNull("session.branch_whole dispatched", branchWholeSent) + + mockEventsFlow.emit( + WsEvent.RpcResult( + "req-1", + mapOf( + "session_id" to "runtime-branch-whole", + "stored_session_id" to "stored-branch-whole", + "title" to "Whole Branch", + ), + ), + ) + advanceUntilIdle() + + assertEquals("runtime-branch-whole", ActiveSessionHolder.activeSessionId.value) + assertEquals("stored-branch-whole", viewModel.uiState.value.currentSessionId) + assertTrue( + "System message 'Session branched' should be added", + viewModel.uiState.value.messages + .any { it.content.contains("Session branched") }, + ) + // No fallback was sent + assertEquals(0, captured.count { it.first == WsMethods.SESSION_BRANCH }) + assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH_WHOLE }) + } + + @Test + fun testSlashCommand_fork_branchWholeError32601_retriesWithSessionBranch() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + val captured = mutableListOf>>() + every { HermesWsClient.send(any(), any(), any()) } answers { + val id = "req-${captured.size + 1}" + captured.add(arg(0) to (arg>(1))) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + viewModel.sendMessage("/fork my-retry-branch") + advanceUntilIdle() + + val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } + assertNotNull("session.branch_whole should be sent", branchWholeSent) + assertEquals("my-retry-branch", branchWholeSent!!.second["name"]) + assertEquals(sessionId, branchWholeSent.second["session_id"]) + + // Backend answers -32601 unknown method + mockEventsFlow.emit( + WsEvent.RpcError( + "req-1", + JsonRpcError(code = -32601, message = "unknown method: session.branch_whole"), + ), + ) + advanceUntilIdle() + + // Retries ONCE with session.branch using same params + val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH } + assertNotNull("session.branch fallback should be sent", branchSent) + assertEquals("my-retry-branch", branchSent!!.second["name"]) + assertEquals(sessionId, branchSent.second["session_id"]) + assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH }) + + // session.branch result is then handled + mockEventsFlow.emit( + WsEvent.RpcResult( + "req-2", + mapOf( + "session_id" to "runtime-branch-fallback", + "stored_session_id" to "stored-branch-fallback", + "title" to "my-retry-branch", + ), + ), + ) + advanceUntilIdle() + + assertEquals("runtime-branch-fallback", ActiveSessionHolder.activeSessionId.value) + assertEquals("stored-branch-fallback", viewModel.uiState.value.currentSessionId) + assertTrue( + "System message 'Session branched' should be added", + viewModel.uiState.value.messages + .any { it.content.contains("Session branched") }, + ) + } + + @Test + fun testSlashCommand_fork_branchWholeOtherError_noFallback() = + runTest { + val (viewModel, _) = createViewModelWithSession() + val captured = mutableListOf>>() + every { HermesWsClient.send(any(), any(), any()) } answers { + val id = "req-${captured.size + 1}" + captured.add(arg(0) to (arg>(1))) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + viewModel.sendMessage("/fork") + advanceUntilIdle() + + val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } + assertNotNull("session.branch_whole should be sent", branchWholeSent) + + // Backend answers with code other than -32601 + mockEventsFlow.emit( + WsEvent.RpcError( + "req-1", + JsonRpcError(code = -32600, message = "invalid request"), + ), + ) + advanceUntilIdle() + + // No retry/fallback sent + assertEquals(0, captured.count { it.first == WsMethods.SESSION_BRANCH }) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("session.branch_whole") == true, + ) + } + @Test fun testSlashCommand_stop_sendsInterrupt() = runTest { @@ -3516,6 +3651,112 @@ class ChatViewModelTest { assertEquals(PendingSendState.UNKNOWN, sendStore.all().single().state) } + @Test + fun completePersistedTurnRetiresOnlyTheReceiptBoundToItsUserRow() = + runTest { + val sendStore = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore) + every { HermesWsClient.sendMessage(sessionId, "Durable", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke("durable-submit") + "durable-submit" + } + viewModel.sendMessage("Durable") + advanceUntilIdle() + mockEventsFlow.emit( + WsEvent.RpcResult("durable-submit", mapOf("status" to "streaming", "user_row_id" to 77)), + ) + advanceUntilIdle() + + val receipt = sendStore.all().single() + assertEquals(77L, receipt.userRowId) + assertEquals( + 77L, + viewModel.uiState.value.messages + .single { it.id == receipt.id } + .serverRowId, + ) + + // Partial and foreign receipts never retire the local turn. + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit( + WsEvent.MessageComplete( + "Reply", + sessionId, + rawPayload = + mapOf( + "persisted_turn" to + mapOf( + "row_ids" to listOf(77), + "complete" to false, + "user_row_id" to 77, + ), + ), + ), + ) + advanceUntilIdle() + assertEquals(listOf(receipt.id), sendStore.all().map { it.id }) + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit( + WsEvent.MessageComplete( + "Reply", + sessionId, + rawPayload = + mapOf( + "persisted_turn" to mapOf("row_ids" to listOf(78), "complete" to true, "user_row_id" to 78), + ), + ), + ) + advanceUntilIdle() + assertEquals(listOf(receipt.id), sendStore.all().map { it.id }) + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit( + WsEvent.MessageComplete( + "Reply", + sessionId, + rawPayload = + mapOf( + "persisted_turn" to + mapOf( + "row_ids" to listOf(77, 79), + "complete" to true, + "user_row_id" to 77, + "final_assistant_row_id" to 79, + ), + ), + ), + ) + advanceUntilIdle() + assertTrue(sendStore.all().isEmpty()) + assertTrue( + viewModel.uiState.value.messages + .any { it.id == receipt.id }, + ) + } + + @Test + fun submitAckWithoutUserRowIdLeavesReceiptUnproven() = + runTest { + val sendStore = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore) + every { HermesWsClient.sendMessage(sessionId, "Legacy", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke("legacy-submit") + "legacy-submit" + } + viewModel.sendMessage("Legacy") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult("legacy-submit", mapOf("status" to "streaming"))) + advanceUntilIdle() + + val receipt = sendStore.all().single() + assertNull(receipt.userRowId) + assertEquals(PendingSendState.ACCEPTED, receipt.state) + assertTrue( + viewModel.uiState.value.messages + .any { it.id == receipt.id && it.serverRowId == null }, + ) + } + @Test fun busyGuideUsesSteerAndUnsupportedFallsBackToQueueWithoutInterrupt() = runTest { @@ -5128,7 +5369,7 @@ class ChatViewModelTest { viewModel.sendMessage("/fork") runCurrent() - val branchRequest = captured.last { it.first == WsMethods.SESSION_BRANCH }.second + val branchRequest = captured.last { it.first == WsMethods.SESSION_BRANCH_WHOLE }.second mockEventsFlow.emit( WsEvent.RpcResult( branchRequest, @@ -5962,11 +6203,11 @@ class ChatViewModelTest { .SessionMessagesResponse(messages = emptyList()), ) - // /fork sends session.branch keyed on the runtime id. + // /fork sends session.branch_whole keyed on the runtime id. viewModel.sendMessage("/fork") advanceUntilIdle() - val branchId = captured.last { it.first == WsMethods.SESSION_BRANCH }.second + val branchId = captured.last { it.first == WsMethods.SESSION_BRANCH_WHOLE }.second val branchStorage = "branch-storage-1" mockEventsFlow.emit( WsEvent.RpcResult( diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index 7b5814ae..6d3dc887 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -1478,4 +1478,131 @@ class ChatWsEventReducerTest { assertTrue(result.state.messages.none { it.id != "orphan" }) assertTrue(!result.streamingState.turnUsageBaselineCaptured) } + + @Test + fun testStatusUpdate_compressingState() { + val initialMessage = ChatMessage(role = MessageRole.USER, content = "hello") + val state = + ChatUiState( + currentSessionId = "session-1", + messages = listOf(initialMessage), + isCompressing = false, + compressionStatus = null, + ) + val event = + WsEvent.StatusUpdate( + status = null, + data = + mapOf( + "kind" to "compressing", + "text" to "⏳ Compressing context...", + ), + ) + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + + assertTrue(result.state.isCompressing) + assertEquals("⏳ Compressing context...", result.state.compressionStatus) + assertEquals(listOf(initialMessage), result.state.messages) + } + + @Test + fun testStatusUpdate_compactingState() { + val state = + ChatUiState( + currentSessionId = "session-1", + isCompressing = false, + compressionStatus = null, + ) + val event = + WsEvent.StatusUpdate( + status = null, + data = + mapOf( + "kind" to "compacting", + "text" to "🗜️ Compacting...", + ), + ) + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + + assertTrue(result.state.isCompressing) + assertEquals("🗜️ Compacting...", result.state.compressionStatus) + } + + @Test + fun testStatusUpdate_compactedState() { + val state = + ChatUiState( + currentSessionId = "session-1", + isCompressing = true, + compressionStatus = "🗜️ Compacting...", + ) + val event = + WsEvent.StatusUpdate( + status = null, + data = + mapOf( + "kind" to "compacted", + "text" to "Finished", + ), + ) + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + + assertFalse(result.state.isCompressing) + // Accepts either "Finished" or null if cleared + assertTrue(result.state.compressionStatus == "Finished" || result.state.compressionStatus == null) + } + + @Test + fun testStatusUpdate_unrelatedKindIgnored() { + val initialMessages = listOf(ChatMessage(role = MessageRole.USER, content = "test")) + val state = + ChatUiState( + currentSessionId = "session-1", + messages = initialMessages, + isCompressing = false, + compressionStatus = "idle", + ) + val event = + WsEvent.StatusUpdate( + status = null, + data = + mapOf( + "kind" to "other", + "text" to "Something else", + ), + ) + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + + assertFalse(result.state.isCompressing) + assertEquals("idle", result.state.compressionStatus) + assertEquals(initialMessages, result.state.messages) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/PersistedEditPreviewHydrationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/PersistedEditPreviewHydrationTest.kt new file mode 100644 index 00000000..57bf99ef --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/PersistedEditPreviewHydrationTest.kt @@ -0,0 +1,327 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.SessionMessage +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests verifying that persisted edit previews survive transcript loading into [ChatMessage] + * and subsequent tool diff parsing via [parseToolOutput]. + */ +class PersistedEditPreviewHydrationTest { + private val json = Json { ignoreUnknownKeys = true } + + private fun parseJson(raw: String): JsonElement = json.parseToJsonElement(raw) + + private fun toolSessionMessage( + content: String, + displayMetadata: JsonElement? = null, + id: Int = 101, + toolCallId: String = "call-101", + ): SessionMessage = + SessionMessage( + id = id, + role = "tool", + content = JsonPrimitive(content), + tool_call_id = toolCallId, + display_metadata = displayMetadata, + ) + + private fun mapSingle(msg: SessionMessage): ChatMessage = + mapServerMessages( + sessionId = "sess-edit-preview", + messages = listOf(msg), + offset = 0, + latestPaging = true, + liveMessages = emptyList(), + ).single() + + // ── Contract 1: ChatServerMessageMapper enrichment ──────────────────────── + + @Test + fun mapServerMessages_enrichesToolContentWithDisplayMetadataInlineDiff() { + val diffPayload = + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1 +1 @@\n-old\n+new" + } + } + """.trimIndent() + val originalContent = """{"output":"File updated successfully"}""" + val serverMsg = + toolSessionMessage( + content = originalContent, + displayMetadata = parseJson(diffPayload), + ) + + val chatMessage = mapSingle(serverMsg) + + val enriched = parseJson(chatMessage.content) as? JsonObject + assertNotNull("Expected enriched content to parse as JSON object", enriched) + assertEquals( + "File updated successfully", + (enriched?.get("output") as? JsonPrimitive)?.content, + ) + val displayMeta = enriched?.get("display_metadata") as? JsonObject + assertNotNull("Expected display_metadata to be added to content", displayMeta) + val toolResultMeta = displayMeta?.get("tool_result_metadata") as? JsonObject + assertNotNull("Expected tool_result_metadata inside display_metadata", toolResultMeta) + val diff = (toolResultMeta?.get("inline_diff") as? JsonPrimitive)?.content + assertEquals("@@ -1 +1 @@\n-old\n+new", diff) + } + + @Test + fun mapServerMessages_doesNotEnrichWhenDisplayMetadataIsNull() { + val originalContent = """{"output":"no metadata"}""" + val serverMsg = + toolSessionMessage( + content = originalContent, + displayMetadata = null, + ) + + val chatMessage = mapSingle(serverMsg) + + assertEquals(originalContent, chatMessage.content) + } + + @Test + fun mapServerMessages_doesNotEnrichWhenRoleIsNotTool() { + val metadata = + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1 +1 @@\n-old\n+new" + } + } + """.trimIndent() + val originalContent = "Assistant says hello" + val serverMsg = + SessionMessage( + id = 202, + role = "assistant", + content = JsonPrimitive(originalContent), + display_metadata = parseJson(metadata), + ) + + val chatMessage = mapSingle(serverMsg) + + assertEquals(originalContent, chatMessage.content) + } + + @Test + fun mapServerMessages_doesNotOverwriteExistingDisplayMetadataInContent() { + val content = + """ + { + "output": "ok", + "display_metadata": { + "existing": true + } + } + """.trimIndent() + val externalMetadata = + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1 +1 @@\n-old\n+new" + } + } + """.trimIndent() + val serverMsg = + toolSessionMessage( + content = content, + displayMetadata = parseJson(externalMetadata), + ) + + val chatMessage = mapSingle(serverMsg) + + val parsed = parseJson(chatMessage.content) as? JsonObject + assertNotNull(parsed) + val meta = parsed?.get("display_metadata") as? JsonObject + assertEquals("true", (meta?.get("existing") as? JsonPrimitive)?.content) + assertNull(meta?.get("tool_result_metadata")) + } + + @Test + fun mapServerMessages_ignoresNonJsonObjectContent() { + val nonJsonContent = "Plain text output from tool" + val metadata = + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1 +1 @@\n-old\n+new" + } + } + """.trimIndent() + val serverMsg = + toolSessionMessage( + content = nonJsonContent, + displayMetadata = parseJson(metadata), + ) + + val chatMessage = mapSingle(serverMsg) + + assertEquals(nonJsonContent, chatMessage.content) + } + + // ── Contract 2: ToolResultParser / FileRenderers inline diff extraction ── + + @Test + fun parseToolOutput_extractsInlineDiffFromNestedToolResultMetadata() { + val rawDiff = "@@ -1,3 +1,3 @@\n-old line\n+new line" + val content = + """ + { + "name": "patch", + "args": { + "path": "app/src/main/Utils.kt" + }, + "display_metadata": { + "tool_result_metadata": { + "inline_diff": "@@ -1,3 +1,3 @@\n-old line\n+new line" + } + } + } + """.trimIndent() + + val view = parseToolOutput(content = content, toolName = "patch", isRunning = false) + + assertNotNull("Expected parseToolOutput to return a ToolView", view) + assertEquals(rawDiff, view?.inlineDiff) + assertEquals("app/src/main/Utils.kt", view?.diffPath) + assertEquals(1, view?.diffStats?.added) + assertEquals(1, view?.diffStats?.removed) + } + + @Test + fun parseToolOutput_extractsInlineDiffFromFlatDisplayMetadata() { + val rawDiff = "--- a/test.kt\n+++ b/test.kt\n@@ -1 +1 @@\n-alpha\n+beta" + val content = + """ + { + "name": "edit_file", + "args": { + "path": "test.kt" + }, + "display_metadata": { + "inline_diff": "--- a/test.kt\n+++ b/test.kt\n@@ -1 +1 @@\n-alpha\n+beta" + } + } + """.trimIndent() + + val view = parseToolOutput(content = content, toolName = "edit_file", isRunning = false) + + assertNotNull(view) + assertEquals(rawDiff, view?.inlineDiff) + assertEquals("test.kt", view?.diffPath) + assertEquals(1, view?.diffStats?.added) + assertEquals(1, view?.diffStats?.removed) + } + + @Test + fun parseToolOutput_extractsInlineDiffFromResultObjectDirectly() { + val rawDiff = "@@ -1 +1 @@\n-before\n+after" + val content = + """ + { + "name": "patch", + "args": { + "path": "file.txt" + }, + "result": { + "inline_diff": "@@ -1 +1 @@\n-before\n+after" + } + } + """.trimIndent() + + val view = parseToolOutput(content = content, toolName = "patch", isRunning = false) + + assertNotNull(view) + assertEquals(rawDiff, view?.inlineDiff) + assertEquals("file.txt", view?.diffPath) + assertEquals(1, view?.diffStats?.added) + assertEquals(1, view?.diffStats?.removed) + } + + @Test + fun parseToolOutput_absentOrEmptyMetadataYieldsNullInlineDiff() { + val emptyDiffContent = + """ + { + "name": "patch", + "args": { + "path": "file.txt" + }, + "display_metadata": { + "tool_result_metadata": { + "inline_diff": "" + } + } + } + """.trimIndent() + val noDiffView = parseToolOutput(content = emptyDiffContent, toolName = "patch", isRunning = false) + assertNotNull(noDiffView) + assertNull(noDiffView?.inlineDiff) + + val missingDiffContent = + """ + { + "name": "patch", + "args": { + "path": "file.txt" + }, + "result": { + "success": true + } + } + """.trimIndent() + val missingDiffView = parseToolOutput(content = missingDiffContent, toolName = "patch", isRunning = false) + assertNotNull(missingDiffView) + assertNull(missingDiffView?.inlineDiff) + } + + // ── End-to-end integration: Transcript hydration -> ToolView diff parsing ─ + + @Test + fun endToEnd_hydratedTranscriptProducesToolViewWithInlineDiff() { + val serverDiff = "@@ -1,2 +1,2 @@\n-val x = 1\n+val x = 2" + val sessionMsg = + toolSessionMessage( + content = """{"name":"patch","args":{"path":"src/Config.kt"},"result":{"success":true}}""", + displayMetadata = + parseJson( + """ + { + "tool_result_metadata": { + "inline_diff": "@@ -1,2 +1,2 @@\n-val x = 1\n+val x = 2" + } + } + """.trimIndent(), + ), + ) + + val chatMessage = mapSingle(sessionMsg) + assertTrue(chatMessage.content.contains("display_metadata")) + + val toolView = + parseToolOutput( + content = chatMessage.content, + toolName = "patch", + isRunning = false, + ) + + assertNotNull("Expected parseToolOutput to succeed for hydrated message content", toolView) + assertEquals(serverDiff, toolView?.inlineDiff) + assertEquals("src/Config.kt", toolView?.diffPath) + assertEquals(1, toolView?.diffStats?.added) + assertEquals(1, toolView?.diffStats?.removed) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt index b85b4f23..272ad6ea 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt @@ -98,6 +98,15 @@ class SlashCommandDispatcherTest { assertEquals(SlashResult.Undo(""), dispatcher.dispatch("/Undo")) } + @Test + fun `compress and compact route to Compress`() { + assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compress")) + assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compact")) + assertEquals(SlashResult.Compress("auth decisions"), dispatcher.dispatch("/compress auth decisions")) + assertEquals(SlashResult.Compress("auth decisions"), dispatcher.dispatch("/compact auth decisions")) + assertEquals(SlashResult.Compress(""), dispatcher.dispatch("/compress ")) + } + @Test fun `NEW uppercase still routes to NewSession`() { // Dispatcher lower-cases before matching, so case must not matter. diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeechTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeechTest.kt new file mode 100644 index 00000000..9136b061 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AndroidSpeechTest.kt @@ -0,0 +1,458 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.Context +import com.m57.hermescontrol.data.model.TtsSpeakRequest +import com.m57.hermescontrol.data.model.TtsSpeakResponse +import com.m57.hermescontrol.data.remote.HermesApiService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import retrofit2.Response +import java.io.File +import java.io.IOException +import java.util.Base64 + +@OptIn(ExperimentalCoroutinesApi::class) +class AndroidSpeechTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private fun createContext(): Context { + val mockContext = mockk() + every { mockContext.applicationContext } returns mockContext + every { mockContext.cacheDir } returns tempFolder.root + return mockContext + } + + private fun listTempFiles(): List = + tempFolder.root + .walkTopDown() + .filter { it.isFile } + .toList() + + @Test + fun `synthesize decodes small real base64 audio bytes to exact file bytes and passes text and profile`() = + runTest { + val context = createContext() + val api = mockk() + val expectedBytes = byteArrayOf(0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00) + val base64Data = Base64.getEncoder().encodeToString(expectedBytes) + val dataUrl = "data:audio/wav;base64,$base64Data" + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = dataUrl, + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = "work-profile", + ) + + val request = + SpeechRequest( + scopeKey = "session-42", + messageId = "msg-101", + text = "Hello world", + ) + + val resultAudio: SpeechAudio = synthesizer.synthesize(request) + assertTrue("Expected result to be FileSpeechAudio", resultAudio is FileSpeechAudio) + + val fileAudio = resultAudio as FileSpeechAudio + val audioFile = fileAudio.file + + assertTrue("Synthesized audio file must exist", audioFile.exists()) + assertArrayEquals("Decoded file bytes must match input bytes", expectedBytes, audioFile.readBytes()) + + coVerify(exactly = 1) { + api.speakText( + request = TtsSpeakRequest(text = "Hello world"), + profile = "work-profile", + ) + } + + fileAudio.dispose() + assertFalse("File must be deleted after dispose()", audioFile.exists()) + } + + @Test + fun `synthesize passes null profile when synthesizer constructed without profile`() = + runTest { + val context = createContext() + val api = mockk() + val rawBytes = byteArrayOf(0x01, 0x02, 0x03, 0x04) + val base64Data = Base64.getEncoder().encodeToString(rawBytes) + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:audio/mp3;base64,$base64Data", + mime_type = "audio/mp3", + provider = null, + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "session-1", + messageId = "msg-1", + text = "Testing null profile", + ) + + val audio = synthesizer.synthesize(request) + assertTrue(audio is FileSpeechAudio) + + coVerify(exactly = 1) { + api.speakText( + request = TtsSpeakRequest(text = "Testing null profile"), + profile = null, + ) + } + + audio.dispose() + assertEquals(0, listTempFiles().size) + } + + @Test + fun `HTTP or network failure throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } throws IOException("Network connection lost") + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = "test-profile", + ) + + val request = + SpeechRequest( + scopeKey = "session-err", + messageId = "msg-err", + text = "Network fail", + ) + + try { + synthesizer.synthesize(request) + fail("Expected IOException to be thrown") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after network failure", 0, listTempFiles().size) + } + + @Test + fun `response with ok false throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = false, + data_url = null, + mime_type = null, + provider = null, + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = "profile-1", + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Failed ok=false", + ) + + try { + synthesizer.synthesize(request) + fail("Expected synthesize to fail when ok is false") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after ok=false", 0, listTempFiles().size) + } + + @Test + fun `malformed data URI scheme throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "not-a-data-uri", + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Malformed scheme", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for malformed data URI") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after malformed URI failure", 0, listTempFiles().size) + } + + @Test + fun `non-audio data URI throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + val textBase64 = Base64.getEncoder().encodeToString("hello world".toByteArray()) + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:text/plain;base64,$textBase64", + mime_type = "text/plain", + provider = "text", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Non audio data URI", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for non-audio data URI") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after non-audio data URI failure", 0, listTempFiles().size) + } + + @Test + fun `invalid base64 payload throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:audio/wav;base64,!!!NotBase64Chars!!!", + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Invalid base64", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for invalid base64") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after base64 decoding failure", 0, listTempFiles().size) + } + + @Test + fun `empty data URI payload throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:audio/wav;base64,", + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Empty payload", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for empty base64 payload") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after empty payload failure", 0, listTempFiles().size) + } + + @Test + fun `null data_url throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = null, + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Null data url", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure when data_url is null") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after null data_url failure", 0, listTempFiles().size) + } + + @Test + fun `padded valid base64 followed by garbage throws exception and leaves no temp files`() = + runTest { + val context = createContext() + val api = mockk() + val validPaddedBase64 = "AQ==" // 1 byte decoded (0x01), padded with == + val payloadWithTrailingGarbage = validPaddedBase64 + "garbage" + + coEvery { api.speakText(any(), any()) } returns + Response.success( + TtsSpeakResponse( + ok = true, + data_url = "data:audio/wav;base64,$payloadWithTrailingGarbage", + mime_type = "audio/wav", + provider = "piper", + ), + ) + + val synthesizer: SpeechSynthesizer = + AndroidSpeechSynthesizer( + context = context, + api = api, + profile = null, + ) + + val request = + SpeechRequest( + scopeKey = "s1", + messageId = "m1", + text = "Garbage after padding", + ) + + try { + synthesizer.synthesize(request) + fail("Expected failure for base64 with trailing garbage after padding") + } catch (e: Exception) { + assertNotNull(e) + } + + assertEquals("No temp files should remain after trailing garbage failure", 0, listTempFiles().size) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechControllerTest.kt new file mode 100644 index 00000000..92e04e14 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechControllerTest.kt @@ -0,0 +1,738 @@ +package com.m57.hermescontrol.ui.chat.components + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SpeechControllerTest { + private class FakeAudio( + val tag: String, + ) : SpeechAudio { + var isDisposed: Boolean = false + private set + + override fun dispose() { + isDisposed = true + } + } + + private class FakeSynthesizer : SpeechSynthesizer { + var calls: MutableList = mutableListOf() + var synthesizeBlock: (suspend (SpeechRequest) -> SpeechAudio)? = null + + override suspend fun synthesize(request: SpeechRequest): SpeechAudio { + calls.add(request) + return synthesizeBlock?.invoke(request) ?: FakeAudio(request.text) + } + } + + private class FakePlayer : SpeechPlayer { + var currentlyPlaying: SpeechAudio? = null + var lastEndedCallback: (() -> Unit)? = null + var lastErrorCallback: (() -> Unit)? = null + var playCallCount: Int = 0 + var stopCallCount: Int = 0 + var playThrows: Throwable? = null + + override fun play( + audio: SpeechAudio, + onEnded: () -> Unit, + onError: () -> Unit, + ) { + playCallCount++ + currentlyPlaying = audio + lastEndedCallback = onEnded + lastErrorCallback = onError + playThrows?.let { throw it } + } + + override fun stop() { + stopCallCount++ + currentlyPlaying = null + lastEndedCallback = null + lastErrorCallback = null + } + } + + private class Fixture( + val scope: TestScope, + ) { + val synthesizer = FakeSynthesizer() + val player = FakePlayer() + val errors = mutableListOf() + + val controller = + SpeechController( + scope = scope, + synthesizer = synthesizer, + player = player, + onError = { errors.add(Unit) }, + ) + } + + @Test + fun `toggle with blank text is a no-op`() = + runTest { + val fixture = Fixture(this) + val blankReq = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = " ") + + fixture.controller.toggle(blankReq) + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.synthesizer.calls.size) + assertEquals(0, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `toggle with empty text is a no-op`() = + runTest { + val fixture = Fixture(this) + val emptyReq = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "") + + fixture.controller.toggle(emptyReq) + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.synthesizer.calls.size) + assertEquals(0, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `toggle synthesizes and plays audio, setting speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(req, fixture.synthesizer.calls[0]) + assertEquals(1, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `toggle on same active request stops playback and clears speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + fixture.controller.toggle(req) + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertTrue(fixture.player.stopCallCount >= 1) + } + + @Test + fun `toggle on same active request while still synthesizing stops and cancels synthesis`() = + runTest { + val fixture = Fixture(this) + val gate = CompletableDeferred() + fixture.synthesizer.synthesizeBlock = { gate.await() } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + fixture.controller.toggle(req) + runCurrent() + + // Toggle same request while synthesis is inflight + fixture.controller.toggle(req) + runCurrent() + + assertNull(fixture.controller.speakingId.value) + + // Even if synthesis completes later, generation fencing ensures it never plays + val audio = FakeAudio("Hello world") + gate.complete(audio) + advanceUntilIdle() + + assertEquals(0, fixture.player.playCallCount) + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `noncooperative synthesis cancelled during synthesis still disposes returned audio without playing`() = + runTest { + val fixture = Fixture(this) + val gate = CompletableDeferred() + val resumeGate = CompletableDeferred() + val audio = FakeAudio("Stale noncooperative audio") + + fixture.synthesizer.synthesizeBlock = { + try { + gate.await() + } catch (t: Throwable) { + withContext(NonCancellable) { + resumeGate.await() + } + } + audio + } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + fixture.controller.toggle(req) + runCurrent() + + // Cancel by stopping + fixture.controller.stop() + runCurrent() + assertNull(fixture.controller.speakingId.value) + + // Now let noncooperative synthesis produce audio after cancellation + resumeGate.complete(Unit) + advanceUntilIdle() + + // Audio must be disposed, never played, no error emitted, and speakingId remains null + assertTrue(audio.isDisposed) + assertEquals(0, fixture.player.playCallCount) + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `noncooperative synthesis superseded by replacement disposes stale audio without touching newer state`() = + runTest { + val fixture = Fixture(this) + val gate1 = CompletableDeferred() + val resumeGate1 = CompletableDeferred() + val audio1 = FakeAudio("Stale noncooperative audio 1") + val audio2 = FakeAudio("New active audio 2") + + fixture.synthesizer.synthesizeBlock = { req -> + if (req.messageId == "msg-1") { + try { + gate1.await() + } catch (t: Throwable) { + withContext(NonCancellable) { + resumeGate1.await() + } + } + audio1 + } else { + audio2 + } + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text") + + fixture.controller.toggle(req1) + runCurrent() + + // Supersede with req2 + fixture.controller.toggle(req2) + advanceUntilIdle() + + // req2 should now be playing + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(1, fixture.player.playCallCount) + + // Now release stale noncooperative synthesis from req1 + resumeGate1.complete(Unit) + advanceUntilIdle() + + // Stale audio1 must be disposed, no extra play calls, speakingId untouched, no errors + assertTrue(audio1.isDisposed) + assertFalse(audio2.isDisposed) + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(1, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `noncooperative synthesis throwing failure after replacement is ignored and does not invoke onError`() = + runTest { + val fixture = Fixture(this) + val gate1 = CompletableDeferred() + val resumeGate1 = CompletableDeferred() + val audio2 = FakeAudio("Active audio 2") + + fixture.synthesizer.synthesizeBlock = { req -> + if (req.messageId == "msg-1") { + try { + gate1.await() + } catch (t: Throwable) { + withContext(NonCancellable) { + resumeGate1.await() + } + } + throw IllegalStateException("Stale synthesis explosion") + } else { + audio2 + } + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text") + + fixture.controller.toggle(req1) + runCurrent() + + // Replace with req2 + fixture.controller.toggle(req2) + advanceUntilIdle() + + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + + // Release stale failure + resumeGate1.complete(Unit) + advanceUntilIdle() + + // Stale failure must not invoke onError or clear msg-2 + assertEquals(0, fixture.errors.size) + assertEquals("msg-2", fixture.controller.speakingId.value) + } + + @Test + fun `stop and restart race on same request ensures stale inflight completion does not disrupt new cycle`() = + runTest { + val fixture = Fixture(this) + val gate1 = CompletableDeferred() + val resumeGate1 = CompletableDeferred() + val audio1 = FakeAudio("Cycle 1 audio") + val audio2 = FakeAudio("Cycle 2 audio") + + var callIndex = 0 + fixture.synthesizer.synthesizeBlock = { + val idx = ++callIndex + if (idx == 1) { + try { + gate1.await() + } catch (t: Throwable) { + withContext(NonCancellable) { + resumeGate1.await() + } + } + audio1 + } else { + audio2 + } + } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Same text") + + // Cycle 1: start synthesis + fixture.controller.toggle(req) + runCurrent() + + // Toggle same request while synthesizing -> cancels cycle 1 + fixture.controller.toggle(req) + runCurrent() + assertNull(fixture.controller.speakingId.value) + + // Cycle 2: restart SAME request immediately + fixture.controller.toggle(req) + advanceUntilIdle() + + // Cycle 2 has synthesized audio2 and is playing it + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(1, fixture.player.playCallCount) + + // Stale cycle 1 finally returns audio1 + resumeGate1.complete(Unit) + advanceUntilIdle() + + // Stale audio1 must be disposed and cycle 2 remains active and playing audio2 + assertTrue(audio1.isDisposed) + assertFalse(audio2.isDisposed) + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(1, fixture.player.playCallCount) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `stop stops playback and clears speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + val stopCallsBefore = fixture.player.stopCallCount + fixture.controller.stop() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertTrue(fixture.player.stopCallCount > stopCallsBefore) + } + + @Test + fun `replacement immediately stops active playback and cancels old request`() = + runTest { + val fixture = Fixture(this) + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text") + + fixture.controller.toggle(req1) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + fixture.controller.toggle(req2) + advanceUntilIdle() + + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(2, fixture.synthesizer.calls.size) + assertEquals(2, fixture.player.playCallCount) + } + + @Test + fun `replacement cancels inflight synthesis and generation fencing rejects stale completion`() = + runTest { + val fixture = Fixture(this) + val gate1 = CompletableDeferred() + val gate2 = CompletableDeferred() + + fixture.synthesizer.synthesizeBlock = { req -> + if (req.messageId == "msg-1") gate1.await() else gate2.await() + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First text") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second text") + + fixture.controller.toggle(req1) + runCurrent() + + fixture.controller.toggle(req2) + runCurrent() + + // Complete old stale synthesis + val audio1 = FakeAudio("First text") + gate1.complete(audio1) + runCurrent() + + // Complete new synthesis + val audio2 = FakeAudio("Second text") + gate2.complete(audio2) + advanceUntilIdle() + + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(1, fixture.player.playCallCount) + assertEquals(audio2, fixture.player.currentlyPlaying) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `player onEnded callback resets speakingId to null`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + val onEnded = fixture.player.lastEndedCallback + onEnded?.invoke() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `stale onEnded callback from superseded generation does not clear active speakingId`() = + runTest { + val fixture = Fixture(this) + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second") + + fixture.controller.toggle(req1) + advanceUntilIdle() + val staleOnEnded = fixture.player.lastEndedCallback + + fixture.controller.toggle(req2) + advanceUntilIdle() + assertEquals("msg-2", fixture.controller.speakingId.value) + + staleOnEnded?.invoke() + advanceUntilIdle() + + // Still speaking msg-2! + assertEquals("msg-2", fixture.controller.speakingId.value) + } + + @Test + fun `stale onError callback from superseded generation is ignored and does not invoke onError`() = + runTest { + val fixture = Fixture(this) + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "First") + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Second") + + fixture.controller.toggle(req1) + advanceUntilIdle() + val staleOnError = fixture.player.lastErrorCallback + + fixture.controller.toggle(req2) + advanceUntilIdle() + assertEquals("msg-2", fixture.controller.speakingId.value) + + staleOnError?.invoke() + advanceUntilIdle() + + assertEquals("msg-2", fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `player onError callback clears speakingId and triggers onError`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + + val onError = fixture.player.lastErrorCallback + onError?.invoke() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(1, fixture.errors.size) + } + + @Test + fun `synthesizer failure triggers onError and clears speakingId`() = + runTest { + val fixture = Fixture(this) + fixture.synthesizer.synthesizeBlock = { + throw IllegalStateException("Synthesis failed") + } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + fixture.controller.toggle(req) + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(1, fixture.errors.size) + assertEquals(0, fixture.player.playCallCount) + } + + @Test + fun `cancellations during synthesis do not trigger onError`() = + runTest { + val fixture = Fixture(this) + val gate = CompletableDeferred() + fixture.synthesizer.synthesizeBlock = { gate.await() } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello world") + fixture.controller.toggle(req) + runCurrent() + + // Stop cancels the inflight coroutine + fixture.controller.stop() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + } + + @Test + fun `replaying same request uses cached audio without re-synthesizing and stop preserves cache`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Cached audio") + + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(1, fixture.player.playCallCount) + + // Stop playback — must preserve replay cache + fixture.controller.stop() + advanceUntilIdle() + assertNull(fixture.controller.speakingId.value) + + // Toggle again with same request -> should replay from cache without re-synthesizing + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(2, fixture.player.playCallCount) + } + + @Test + fun `request with different text invalidates cache and disposes old audio`() = + runTest { + val fixture = Fixture(this) + val audio1 = FakeAudio("Text 1") + val audio2 = FakeAudio("Text 2") + fixture.synthesizer.synthesizeBlock = { req -> + if (req.text == "Text 1") audio1 else audio2 + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Text 1") + fixture.controller.toggle(req1) + advanceUntilIdle() + assertEquals(audio1, fixture.player.currentlyPlaying) + assertFalse(audio1.isDisposed) + + val req2 = SpeechRequest(scopeKey = "session-1", messageId = "msg-2", text = "Text 2") + fixture.controller.toggle(req2) + advanceUntilIdle() + + assertEquals(audio2, fixture.player.currentlyPlaying) + assertTrue(audio1.isDisposed) + assertEquals(2, fixture.synthesizer.calls.size) + } + + @Test + fun `request with different scope invalidates cache and disposes old audio`() = + runTest { + val fixture = Fixture(this) + val audio1 = FakeAudio("Scope 1 audio") + val audio2 = FakeAudio("Scope 2 audio") + fixture.synthesizer.synthesizeBlock = { req -> + if (req.scopeKey == "session-1") audio1 else audio2 + } + + val req1 = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Hello") + fixture.controller.toggle(req1) + advanceUntilIdle() + assertFalse(audio1.isDisposed) + + val req2 = SpeechRequest(scopeKey = "session-2", messageId = "msg-1", text = "Hello") + fixture.controller.toggle(req2) + advanceUntilIdle() + + assertEquals(audio2, fixture.player.currentlyPlaying) + assertTrue(audio1.isDisposed) + assertEquals(2, fixture.synthesizer.calls.size) + } + + @Test + fun `replaying cached request when player throws does not escape toggle and clears speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Cached text") + + // Initial successful playback + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(1, fixture.player.playCallCount) + + // Complete initial playback + fixture.player.lastEndedCallback?.invoke() + advanceUntilIdle() + assertNull(fixture.controller.speakingId.value) + assertEquals(0, fixture.errors.size) + + // Configure player to throw synchronously on replay + val stopCallsBeforeReplay = fixture.player.stopCallCount + fixture.player.playThrows = IllegalStateException("Player init failed") + + // Replay exact cached request + fixture.controller.toggle(req) + advanceUntilIdle() + + // Must NOT escape toggle, speakingId null, onError exactly once, + // partially initialized player stopped, no resynthesis + assertNull(fixture.controller.speakingId.value) + assertEquals(1, fixture.errors.size) + assertTrue(fixture.player.stopCallCount > stopCallsBeforeReplay) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(2, fixture.player.playCallCount) + + // Replay cache is retained: if player recovers, replaying exact request does not re-synthesize + fixture.player.playThrows = null + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(3, fixture.player.playCallCount) + assertEquals(1, fixture.errors.size) + } + + @Test + fun `initial play when player throws does not escape toggle and clears speakingId`() = + runTest { + val fixture = Fixture(this) + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Initial throw text") + + fixture.player.playThrows = IllegalStateException("Player failed synchronously on first play") + + // Initial noncached play throw + fixture.controller.toggle(req) + advanceUntilIdle() + + // Must NOT escape toggle, speakingId null, onError exactly once, stopped partially initialized player + assertNull(fixture.controller.speakingId.value) + assertEquals(1, fixture.errors.size) + assertTrue(fixture.player.stopCallCount >= 1) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(1, fixture.player.playCallCount) + + // Replay cache retained: if player recovers, next toggle on exact + // request uses cached audio without re-synthesizing + fixture.player.playThrows = null + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals("msg-1", fixture.controller.speakingId.value) + assertEquals(1, fixture.synthesizer.calls.size) + assertEquals(2, fixture.player.playCallCount) + assertEquals(1, fixture.errors.size) + } + + @Test + fun `reset clears speakingId, stops player, and disposes cached audio`() = + runTest { + val fixture = Fixture(this) + val audio = FakeAudio("Reset audio") + fixture.synthesizer.synthesizeBlock = { audio } + + val req = SpeechRequest(scopeKey = "session-1", messageId = "msg-1", text = "Reset audio") + fixture.controller.toggle(req) + advanceUntilIdle() + assertEquals("msg-1", fixture.controller.speakingId.value) + assertFalse(audio.isDisposed) + + fixture.controller.reset() + advanceUntilIdle() + + assertNull(fixture.controller.speakingId.value) + assertTrue(audio.isDisposed) + assertTrue(fixture.player.stopCallCount >= 1) + + // Re-toggling same request after reset must re-synthesize because cache was cleared/disposed + val audio2 = FakeAudio("Reset audio 2") + fixture.synthesizer.synthesizeBlock = { audio2 } + fixture.controller.toggle(req) + advanceUntilIdle() + + assertEquals(2, fixture.synthesizer.calls.size) + assertEquals("msg-1", fixture.controller.speakingId.value) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechTextTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechTextTest.kt new file mode 100644 index 00000000..c4c7d4cd --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SpeechTextTest.kt @@ -0,0 +1,250 @@ +package com.m57.hermescontrol.ui.chat.components + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SpeechTextTest { + @Test + fun `blank input returns blank string`() { + assertEquals("", SpeechText.stripMarkdownForSpeech("")) + assertEquals("", SpeechText.stripMarkdownForSpeech(" ")) + assertEquals("", SpeechText.stripMarkdownForSpeech("\n\t\n ")) + } + + @Test + fun `plain text is preserved without changes`() { + val input = "Hello world this is a test" + val result = SpeechText.stripMarkdownForSpeech(input) + assertEquals("Hello world this is a test", result.trim()) + } + + @Test + fun `bold and italic markers are removed keeping prose`() { + val input = "This is **bold** text and *italic* text and ***bold italic*** text" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("*")) + assertTrue(result.contains("bold")) + assertTrue(result.contains("italic")) + assertTrue(result.contains("bold italic")) + } + + @Test + fun `strikethrough markers are removed keeping inner text`() { + val input = "This is ~~cancelled~~ completed" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("~")) + assertTrue(result.contains("cancelled")) + assertTrue(result.contains("completed")) + } + + @Test + fun `headers keep title text without hash characters`() { + val input = + """ + # Header 1 + ## Header 2 + ### Header 3 + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("#")) + assertTrue(result.contains("Header 1")) + assertTrue(result.contains("Header 2")) + assertTrue(result.contains("Header 3")) + } + + @Test + fun `inline code keeps code content without backticks`() { + val input = "Run `val my_value = 42` to configure" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("`")) + assertTrue(result.contains("val my_value = 42")) + assertTrue(result.contains("my_value")) + assertTrue(result.contains("to configure")) + assertFalse(result.contains(" ")) + } + + @Test + fun `fenced code block keeps code body and removes fences and language identifier`() { + val input = + """ + Here is some code: + ```kotlin + fun main() { + println("Hello") + } + ``` + Done. + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("```")) + assertFalse(result.contains("kotlin")) + assertTrue(result.contains("fun main()")) + assertTrue(result.contains("""println("Hello")""")) + assertTrue(result.contains("Here is some code:")) + assertTrue(result.contains("Done.")) + } + + @Test + fun `fenced code block body retains underscores, generics, multiplication, and pipes`() { + val input = + """ + Here is complex code: + ```typescript + const a_b_c: List = items.map(x => x * 2 | 0); + ``` + Done... + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("```")) + assertFalse(result.contains("typescript")) + assertTrue(result.contains("a_b_c")) + assertTrue(result.contains("")) + assertTrue(result.contains("* 2")) + assertTrue(result.contains("| 0")) + assertTrue(result.contains("Here is complex code:")) + assertTrue(result.contains("Done...")) + } + + @Test + fun `markdown links keep label text and strip destination url`() { + val input = "Check out [Hermes Documentation](https://hermes-agent.nousresearch.com/docs) for info" + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("Hermes Documentation")) + assertFalse(result.contains("https://hermes-agent.nousresearch.com/docs")) + assertFalse(result.contains("[") || result.contains("]")) + } + + @Test + fun `image markdown keeps alt text and strips image url`() { + val input = "Here is a diagram: ![Architecture diagram](https://example.com/arch.png)" + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("Architecture diagram")) + assertFalse(result.contains("https://example.com/arch.png")) + assertFalse(result.contains("![")) + } + + @Test + fun `bare urls are removed`() { + val input = "Visit https://example.com or http://test.org/path?arg=1 today" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("https://example.com")) + assertFalse(result.contains("http://test.org/path?arg=1")) + assertTrue(result.contains("Visit")) + assertTrue(result.contains("today")) + } + + @Test + fun `html tags are stripped keeping inner text`() { + val input = "

This is a bold paragraph with a
break.

" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("<")) + assertFalse(result.contains(">")) + assertTrue(result.contains("This is a")) + assertTrue(result.contains("bold")) + assertTrue(result.contains("paragraph with a")) + assertTrue(result.contains("break.")) + } + + @Test + fun `blockquote markers are removed keeping content`() { + val input = + """ + > This is a quote. + > It spans multiple lines. + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains(">")) + assertTrue(result.contains("This is a quote.")) + assertTrue(result.contains("It spans multiple lines.")) + } + + @Test + fun `unordered and ordered list markers are handled sensibly`() { + val input = + """ + Items: + * First item + * Second item + 1. Step one + 2. Step two + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("*")) + assertFalse(result.contains("1.")) + assertFalse(result.contains("2.")) + assertTrue(result.contains("First item")) + assertTrue(result.contains("Second item")) + assertTrue(result.contains("Step one")) + assertTrue(result.contains("Step two")) + } + + @Test + fun `arabic text is preserved intact`() { + val input = "مرحبا بك في تطبيق هيرمس! **أهلا وسهلا**" + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains("*")) + assertTrue(result.contains("مرحبا بك في تطبيق هيرمس!")) + assertTrue(result.contains("أهلا وسهلا")) + } + + @Test + fun `unicode and emoji are preserved`() { + val input = "Hello 👋 world! 🚀 Enjoy the day ☀️" + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("👋")) + assertTrue(result.contains("🚀")) + assertTrue(result.contains("☀️")) + assertTrue(result.contains("Hello")) + assertTrue(result.contains("world!")) + } + + @Test + fun `punctuation and code punctuation edge cases are handled without double punctuation`() { + val input = "Wait... is that it?! Yes, it is: done." + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("Wait...")) + assertTrue(result.contains("done.")) + assertFalse(result.contains("..,")) + assertFalse(result.contains(",,")) + } + + @Test + fun `newlines retain punctuation without introducing duplicate or mixed punctuation and normalize whitespace`() { + val input = + """ + Done. + Next! + مرحبا؟ + Again + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertFalse(result.contains(". .")) + assertFalse(result.contains("!.")) + assertFalse(result.contains("؟.")) + assertFalse(result.contains("\n\n")) + assertTrue(result.contains("Done.")) + assertTrue(result.contains("Next!")) + assertTrue(result.contains("مرحبا؟")) + assertTrue(result.contains("Again")) + } + + @Test + fun `horizontal rules are removed`() { + val input = + """ + Section 1 + --- + Section 2 + *** + Section 3 + """.trimIndent() + val result = SpeechText.stripMarkdownForSpeech(input) + assertTrue(result.contains("Section 1")) + assertTrue(result.contains("Section 2")) + assertTrue(result.contains("Section 3")) + assertFalse(result.contains("---")) + assertFalse(result.contains("***")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ConnectorToolLabelsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ConnectorToolLabelsTest.kt new file mode 100644 index 00000000..6970008a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ConnectorToolLabelsTest.kt @@ -0,0 +1,40 @@ +package com.m57.hermescontrol.ui.chat.tool + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectorToolLabelsTest { + @Test fun `gateway envelope labels reach the visible header without replacing real tool arguments`() { + val content = """{ + "name":"tool_call", + "args":{"labels":["bug"]}, + "labels":[{"app":"GitHub","text":"Create issue"}], + "result":{} + }""" + val view = + com.m57.hermescontrol.ui.chat + .parseToolOutput(content, "tool_call", false) + assertEquals("GitHub · Create issue", view?.serverDisplayName) + assertEquals("GitHub · Create issue", view?.title) + } + + @Test fun `server connector labels title both live and restored tool calls`() { + val args = + Json.parseToJsonElement( + """{"hermes_tool_labels":[{"app":"Google Drive","text":"List files","kind":"connector"}]}""", + ) + val running = ToolViewBuilder.build("tool_call", args, null, running = true) + val restored = ToolViewBuilder.build("tool_call", args, Json.parseToJsonElement("{}")) + assertEquals("Google Drive · List files", running.title) + assertEquals(running.title, restored.title) + } + + @Test fun `malformed metadata and actual labels argument keep fallback title`() { + val args = Json.parseToJsonElement("""{"labels":["bug"],"hermes_tool_labels":[null,42,{"app":"GitHub"}]}""") + val view = ToolViewBuilder.build("tool_call", args, null) + assertTrue(view.title.isNotBlank()) + assertEquals(ToolViewBuilder.build("tool_call", null, null).title, view.title) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt new file mode 100644 index 00000000..e6ff4c9e --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt @@ -0,0 +1,207 @@ +package com.m57.hermescontrol.ui.connectors + +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.DataScope +import com.m57.hermescontrol.data.model.AccountConnectorResult +import com.m57.hermescontrol.data.model.ConnectorCatalogEntry +import com.m57.hermescontrol.data.model.ConnectorError +import com.m57.hermescontrol.data.model.ConnectorListResult +import com.m57.hermescontrol.data.model.ConnectorPolicy +import com.m57.hermescontrol.data.ws.AccountConnectorRepository +import com.m57.hermescontrol.data.ws.ConnectionOperationParser +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.data.ws.WsMethods +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AccountConnectorsViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val repo = mockk() + private val scopes = MutableStateFlow(DataScope("server", "http://host", "work", 1)) + private val events = MutableSharedFlow() + private lateinit var vm: AccountConnectorsViewModel + private val policy = ConnectorPolicy("effective", "unrestricted", emptyList(), emptyList(), emptyMap()) + + @Before fun setup() { + Dispatchers.setMain(dispatcher) + coEvery { repo.listConnectors() } returns ConnectorListResult.Success(true, emptyList()) + coEvery { repo.catalog() } returns + AccountConnectorResult.Success(listOf(ConnectorCatalogEntry("drive", "Google Drive", "Files", "storage"))) + coEvery { repo.accounts() } returns AccountConnectorResult.Success(emptyList()) + coEvery { repo.policy() } returns + AccountConnectorResult.Success(policy.copy(member = policy.copy(revision = "member"))) + coEvery { repo.operationStatus("op-a") } returns AccountConnectorResult.Success(snapshot()) + vm = AccountConnectorsViewModel(repo, scopes, { scopes.value }, events) + } + + @After fun teardown() { + vm.viewModelScope.cancel() + Dispatchers.resetMain() + } + + @Test fun `no session required and connect retains the browser operation`() = + runTest(dispatcher) { + advanceUntilIdle() + assertFalse(vm.state.value.loading) + assertEquals( + "Google Drive", + vm.state.value.catalog + .single() + .name, + ) + coEvery { repo.connect(listOf("drive"), false) } returns AccountConnectorResult.Success(snapshot()) + vm.connect("drive") + vm.connect("drive") + advanceUntilIdle() + coVerify(exactly = 1) { repo.connect(listOf("drive"), false) } + assertEquals( + "https://example.com/auth", + vm.connectionState.value.operation + ?.targets + ?.single() + ?.safeConnectUrl, + ) + } + + @Test fun `connect and remove errors remain visible rather than cleared by reload`() = + runTest(dispatcher) { + advanceUntilIdle() + coEvery { repo.connect(any(), any()) } returns + AccountConnectorResult.Failure(ConnectorError.RequestFailed("authorization failed")) + vm.connect("drive") + advanceUntilIdle() + assertEquals("authorization failed", vm.state.value.error) + coEvery { repo.remove("c1") } returns + AccountConnectorResult.Failure(ConnectorError.RequestFailed("remove failed")) + vm.remove("c1") + advanceUntilIdle() + assertEquals("remove failed", vm.state.value.error) + assertFalse(vm.state.value.busy) + } + + @Test fun `profile switch clears operation and rejects prior account update`() = + runTest(dispatcher) { + advanceUntilIdle() + coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot()) + vm.connect("drive") + advanceUntilIdle() + scopes.value = scopes.value?.copy(activeProfileId = "other") + advanceUntilIdle() + events.emit(WsEvent.ConnectionUpdate(snapshot().copy(seq = 2))) + advanceUntilIdle() + assertNull(vm.connectionState.value.operation) + assertEquals( + "other", + vm.state.value.scope + ?.activeProfileId, + ) + } + + @Test fun `late tool response cannot populate a new account scope`() = + runTest(dispatcher) { + advanceUntilIdle() + val pending = + CompletableDeferred>>() + coEvery { repo.tools("drive", false) } coAnswers { pending.await() } + vm.loadTools("drive") + dispatcher.scheduler.runCurrent() + scopes.value = scopes.value?.copy(inMemoryAuthGeneration = 2) + advanceUntilIdle() + pending.complete(AccountConnectorResult.Success(emptyList())) + advanceUntilIdle() + assertTrue( + vm.state.value.tools + .isEmpty(), + ) + assertTrue( + vm.state.value.toolsLoading + .isEmpty(), + ) + } + + @Test fun `continue answers account operation and settlement closes it`() = + runTest(dispatcher) { + advanceUntilIdle() + coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot()) + coEvery { repo.operationRequest(any(), any()) } returns mapOf("status" to "ok") + coEvery { repo.operationStatus("op-a") } returns + AccountConnectorResult.Success(snapshot().copy(seq = 2, settled = true)) + vm.connect("drive") + advanceUntilIdle() + vm.continueOperation() + advanceUntilIdle() + coVerify { + repo.operationRequest( + WsMethods.CONNECTION_RESPOND, + mapOf( + "owner" to mapOf("type" to "account"), + "op_id" to "op-a", + "result" to mapOf("settled_by" to "continue"), + ), + ) + } + assertNull(vm.connectionState.value.operation) + } + + @Test fun `session update never changes account operation`() = + runTest(dispatcher) { + advanceUntilIdle() + coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot()) + vm.connect("drive") + advanceUntilIdle() + events.emit( + WsEvent.ConnectionUpdate( + snapshot().copy(seq = 3, settled = true, accountOwned = false, sessionId = "chat"), + ), + ) + advanceUntilIdle() + assertEquals( + 1L, + vm.connectionState.value.operation + ?.seq, + ) + } + + private fun snapshot() = + checkNotNull( + ConnectionOperationParser.parse( + mapOf( + "owner" to mapOf("type" to "account"), + "op_id" to "op-a", + "seq" to 1, + "deadline_at" to 2000000000.0, + "settled" to false, + "targets" to + listOf( + mapOf( + "name" to "drive", + "kind" to "connector", + "action" to "authorize", + "state" to "initiated", + "connect_url" to "https://example.com/auth", + ), + ), + ), + ), + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt new file mode 100644 index 00000000..81c7c7a1 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt @@ -0,0 +1,193 @@ +package com.m57.hermescontrol.ui.mcp + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.McpServer +import com.m57.hermescontrol.data.model.McpServerTestResponse +import com.m57.hermescontrol.data.model.McpServersResponse +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.remote.OkHttpProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Response + +@OptIn(ExperimentalCoroutinesApi::class) +class McpOwnershipTest { + private val dispatcher = StandardTestDispatcher() + private lateinit var api: HermesApiService + private val owned = McpServer(name = "scanner", enabled = true, source = "plugin", plugin = "security") + private val conflict = "Server 'scanner' is provided by plugin 'security' and cannot be modified" + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + api = mockk() + mockkObject(ApiClient, AuthManager) + every { ApiClient.hermesApi } returns api + every { AuthManager.currentDataScope() } throws IllegalStateException("No test scope") + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `ownership decodes and legacy servers remain editable`() { + val json = OkHttpProvider.json + val plugin = + json.decodeFromString( + """{"name":"scanner","enabled":true,"source":"plugin","plugin":"security"}""", + ) + assertEquals(owned, plugin) + assertTrue(plugin.isPluginOwned) + assertTrue(plugin.copy(plugin = null).isPluginOwned) + val legacy = json.decodeFromString("""{"name":"local","enabled":true}""") + assertFalse(legacy.isPluginOwned) + assertNull(legacy.plugin) + val config = + json.decodeFromString( + """{"name":"local","enabled":true,"source":"config","plugin":null}""", + ) + assertFalse(config.isPluginOwned) + } + + @Test + fun `plugin mutations are blocked even for stale callbacks but testing remains available`() { + val vm = loaded(owned) + val stale = owned.copy(source = "config", plugin = null) + vm.toggleServer(stale) + vm.deleteServer(owned.name) + vm.startEditingEnv(stale) + vm.updateEnvKey("KEY") + vm.updateEnvValue("value") + vm.addEnvVar(owned.name) + vm.removeEnvVar(owned.name, "KEY") + vm.startMcpOAuthFlow(stale) { error("Must not open browser") } + dispatcher.scheduler.advanceUntilIdle() + assertTrue( + vm.uiState.value.servers + .single() + .enabled, + ) + assertNull(vm.uiState.value.editingEnvFor) + coVerify(exactly = 0) { api.toggleMcpServer(any(), any()) } + coVerify(exactly = 0) { api.deleteMcpServer(any()) } + coVerify(exactly = 0) { api.updateMcpServer(any(), any()) } + coVerify(exactly = 0) { api.authMcpServer(any()) } + coEvery { api.testMcpServer(owned.name) } returns Response.success(McpServerTestResponse(ok = true)) + vm.testServer(owned.name) + dispatcher.scheduler.advanceUntilIdle() + assertTrue( + vm.uiState.value.serverTestResults + .getValue(owned.name) + .ok, + ) + } + + @Test + fun `config server toggle and environment editing remain available`() { + val config = owned.copy(source = "config", plugin = null) + val vm = loaded(config) + coEvery { api.toggleMcpServer(config.name, any()) } returns Response.success(Unit) + vm.toggleServer(config) + dispatcher.scheduler.advanceUntilIdle() + assertFalse( + vm.uiState.value.servers + .single() + .enabled, + ) + vm.startEditingEnv(config) + assertEquals(config.name, vm.uiState.value.editingEnvFor) + coEvery { api.updateMcpServer(config.name, any()) } returns Response.success(config) + vm.updateEnvKey("KEY") + vm.updateEnvValue("value") + vm.addEnvVar(config.name) + dispatcher.scheduler.advanceUntilIdle() + coVerify { api.updateMcpServer(config.name, mapOf("env" to mapOf("KEY" to "value"))) } + vm.removeEnvVar(config.name, "KEY") + dispatcher.scheduler.advanceUntilIdle() + coVerify { api.updateMcpServer(config.name, mapOf("env" to emptyMap())) } + coEvery { api.deleteMcpServer(config.name) } returns Response.success(Unit) + vm.deleteServer(config.name) + dispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 1) { api.deleteMcpServer(config.name) } + } + + @Test + fun `stale ownership conflicts surface detail verbatim and revert toggle`() { + val config = owned.copy(source = "config", plugin = null) + val vm = loaded(config) + coEvery { api.toggleMcpServer(any(), any()) } returns conflictResponse() + vm.toggleServer(config) + dispatcher.scheduler.advanceUntilIdle() + assertTrue( + vm.uiState.value.servers + .single() + .enabled, + ) + assertEquals(conflict, vm.uiState.value.toastMessage) + coEvery { api.deleteMcpServer(any()) } returns conflictResponse() + vm.deleteServer(config.name) + dispatcher.scheduler.advanceUntilIdle() + assertEquals(conflict, vm.uiState.value.toastMessage) + coEvery { api.updateMcpServer(any(), any()) } coAnswers { conflictResponse() } + vm.updateEnvKey("KEY") + vm.addEnvVar(config.name) + dispatcher.scheduler.advanceUntilIdle() + assertEquals(conflict, vm.uiState.value.toastMessage) + vm.clearToast() + vm.removeEnvVar(config.name, "KEY") + dispatcher.scheduler.advanceUntilIdle() + assertEquals(conflict, vm.uiState.value.toastMessage) + coEvery { api.authMcpServer(any()) } returns conflictResponse() + vm.clearToast() + vm.startMcpOAuthFlow(config) { error("Must not open browser") } + dispatcher.scheduler.advanceUntilIdle() + assertEquals(conflict, vm.uiState.value.toastMessage) + } + + @Test + fun `add collision surfaces plugin conflict verbatim`() { + coEvery { api.addMcpServer(any()) } returns conflictResponse() + val vm = McpServersViewModel(ioDispatcher = dispatcher) + vm.toggleAddForm() + vm.updateAddServerName(owned.name) + vm.updateAddServerUrl("https://example.com/mcp") + vm.submitAddServer() + dispatcher.scheduler.advanceUntilIdle() + assertEquals(conflict, vm.uiState.value.toastMessage) + assertFalse(vm.uiState.value.addingServer) + assertTrue(vm.uiState.value.showAddForm) + assertEquals(owned.name, vm.uiState.value.addServerName) + } + + private fun loaded(server: McpServer): McpServersViewModel { + coEvery { api.getMcpServers() } returns Response.success(McpServersResponse(listOf(server))) + return McpServersViewModel(ioDispatcher = dispatcher).also { + it.loadServers() + dispatcher.scheduler.advanceUntilIdle() + assertEquals(listOf(server), it.uiState.value.servers) + } + } + + private fun conflictResponse(): Response = Response.error(409, """{"detail":"$conflict"}""".toResponseBody()) +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt index 7c8c759f..92c981d8 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt @@ -5,6 +5,8 @@ import com.m57.hermescontrol.data.model.PluginCatalogCapabilities import com.m57.hermescontrol.data.model.PluginCatalogEntry import com.m57.hermescontrol.data.model.PluginCatalogResponse import com.m57.hermescontrol.data.model.PluginInfo +import com.m57.hermescontrol.data.model.PluginUpdateRequest +import com.m57.hermescontrol.data.model.PluginUpdateResult import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService @@ -72,6 +74,107 @@ class PluginsViewModelTest { unmockkAll() } + @Test + fun `update waits for consent and only accepts on explicit confirmation`() { + coEvery { mockApi.updatePlugin("snyk", PluginUpdateRequest(false)) } returns + Response.success(PluginUpdateResult(consentRequired = true, deltaLines = listOf("tools: scan"))) + coEvery { mockApi.updatePlugin("snyk", PluginUpdateRequest(true)) } returns + Response.success(PluginUpdateResult(ok = true)) + coEvery { mockApi.getPlugins() } returns Response.success(PluginsHubResponse(emptyList())) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + assertEquals( + listOf("tools: scan"), + viewModel.uiState.value.updateConsent + ?.result + ?.capabilityDelta, + ) + assertNull(viewModel.uiState.value.toastMessage) + assertNull(viewModel.uiState.value.rowBusy) + coVerify(exactly = 0) { mockApi.updatePlugin(any(), PluginUpdateRequest(true)) } + coVerify(exactly = 0) { mockApi.getPlugins() } + viewModel.confirmPluginUpdate() + viewModel.confirmPluginUpdate() + testDispatcher.scheduler.advanceUntilIdle() + assertNull(viewModel.uiState.value.updateConsent) + assertEquals("Plugin updated successfully", viewModel.uiState.value.toastMessage) + coVerify(exactly = 1) { mockApi.updatePlugin("snyk", PluginUpdateRequest(false)) } + coVerify(exactly = 1) { mockApi.updatePlugin("snyk", PluginUpdateRequest(true)) } + coVerify(exactly = 1) { mockApi.getPlugins() } + } + + @Test + fun `cancel consent never retries the update`() { + coEvery { mockApi.updatePlugin(any(), any()) } returns + Response.success(PluginUpdateResult(consentRequired = true)) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + viewModel.cancelPluginUpdate() + viewModel.confirmPluginUpdate() + testDispatcher.scheduler.advanceUntilIdle() + assertNull(viewModel.uiState.value.updateConsent) + assertNull(viewModel.uiState.value.toastMessage) + coVerify(exactly = 1) { mockApi.updatePlugin(any(), any()) } + } + + @Test + fun `ok false is a failure even with HTTP 200 and unchanged true`() { + coEvery { mockApi.updatePlugin(any(), any()) } returns + Response.success(PluginUpdateResult(unchanged = true, error = "Update refused")) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("Failed to update plugin: Update refused", viewModel.uiState.value.toastMessage) + assertNull(viewModel.uiState.value.rowBusy) + coVerify(exactly = 0) { mockApi.getPlugins() } + } + + @Test + fun `unchanged success reports already up to date`() { + coEvery { mockApi.updatePlugin(any(), any()) } returns + Response.success(PluginUpdateResult(ok = true, unchanged = true)) + coEvery { mockApi.getPlugins() } returns Response.success(PluginsHubResponse(emptyList())) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("Plugin is already up to date", viewModel.uiState.value.toastMessage) + assertNull(viewModel.uiState.value.rowBusy) + } + + @Test + fun `scope switch discards late update consent`() { + val reply = CompletableDeferred>() + coEvery { mockApi.updatePlugin(any(), any()) } coAnswers { reply.await() } + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.runCurrent() + viewModel.clearScopeOwnedState() + reply.complete(Response.success(PluginUpdateResult(consentRequired = true))) + testDispatcher.scheduler.advanceUntilIdle() + viewModel.confirmPluginUpdate() + assertNull(viewModel.uiState.value.updateConsent) + assertNull(viewModel.uiState.value.rowBusy) + assertNull(viewModel.uiState.value.toastMessage) + coVerify(exactly = 1) { mockApi.updatePlugin(any(), any()) } + } + + @Test + fun `HTTP update failure releases busy state`() { + coEvery { mockApi.updatePlugin(any(), any()) } returns Response.error(503, "offline".toResponseBody()) + val viewModel = PluginsViewModel() + viewModel.updatePlugin("snyk") + testDispatcher.scheduler.advanceUntilIdle() + assertTrue( + viewModel.uiState.value.toastMessage!! + .contains("Failed to update"), + ) + assertNull(viewModel.uiState.value.rowBusy) + coVerify(exactly = 0) { mockApi.getPlugins() } + } + @Test fun `switch tab to CATALOG automatically triggers catalog load`() { coEvery { mockApi.getPluginCatalog() } returns diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index 43cbe479..2194e827 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -99,6 +99,72 @@ class SessionsViewModelTest { unmockkAll() } + @Test + fun `corrupt refresh retains history and recovery replaces it`() { + coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returnsMany + listOf( + Response.success(SessionListResponse(listOf(SessionInfo("cached")), total = 1)), + Response.success( + SessionListResponse(emptyList(), storage = mapOf("default" to "corrupt")), + ), + Response.success(SessionListResponse(emptyList())), + ) + val vm = createViewModel() + vm.loadSessions() + testDispatcher.scheduler.advanceUntilIdle() + vm.loadSessions(forceRefresh = true) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals( + listOf("cached"), + vm.uiState.value.sessions + .map { it.id }, + ) + assertEquals(setOf("default"), vm.uiState.value.corruptStorageProfiles) + assertFalse(vm.uiState.value.isLoading) + assertFalse(vm.uiState.value.hasMore) + vm.loadSessions(forceRefresh = true) + testDispatcher.scheduler.advanceUntilIdle() + assertTrue( + vm.uiState.value.sessions + .isEmpty(), + ) + assertTrue( + vm.uiState.value.corruptStorageProfiles + .isEmpty(), + ) + } + + @Test + fun `corrupt first page retains partial rows and flags empty history`() { + val response = + kotlinx.serialization.json.Json.decodeFromString( + """{"sessions":[],"storage":{"work":"corrupt"}}""", + ) + coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returns Response.success(response) + val vm = createViewModel() + vm.loadSessions() + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(setOf("work"), vm.uiState.value.corruptStorageProfiles) + assertFalse(vm.uiState.value.isLoading) + coEvery { mockApi.getSessions(any(), any(), any(), any(), any()) } returns + Response.success(response.copy(sessions = listOf(SessionInfo("partial")))) + vm.loadSessions(forceRefresh = true) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals( + listOf("partial"), + vm.uiState.value.sessions + .map { it.id }, + ) + assertEquals(setOf("work"), vm.uiState.value.corruptStorageProfiles) + assertTrue( + kotlinx.serialization.json.Json + .decodeFromString( + """{"sessions":[]}""", + ).storage + .isEmpty(), + ) + } + @Test fun `wire paging metadata drives a second request without changing history paging`() { val first =