release: promote v1.31.0-rc.2 to main (#1354)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Lx-204 <15377891490@163.com>
Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com>
Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com>
Co-authored-by: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-28 12:39:34 +03:00
1 parent e4004decb4
commit ed915d0816
111 files changed
+10563 -998

No files matched your search

+23 -1
View File
@@ -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
@@ -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<ChatViewModel>(relaxed = true)
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
every { mockViewModel.connectionOperationState } returns
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
every { mockViewModel.actionProgress } returns
ActionProgressController(scope = CoroutineScope(Dispatchers.Main))
every { mockViewModel.sendMessage(any(), any()) } returns true
composeTestRule.setContent {
ChatScreen(
onOpenDrawer = {},
sessionId = null,
viewModel = mockViewModel,
)
}
composeTestRule.onNodeWithTag("chat_input").performTextInput("Use my saved default")
composeTestRule.onNodeWithTag("send_button").performClick()
verify(exactly = 1) { mockViewModel.sendMessage("Use my saved default", null) }
verify(exactly = 0) { mockViewModel.sendMessage("Use my saved default", BusySendMode.CORRECT) }
}
}
@@ -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<Attachment> = emptyList(),
onSend: () -> Unit = {},
onBusySend: (BusySendMode) -> Unit = {},
isConnected: Boolean = true,
pendingReasoningLevel: String? = null,
) {
composeTestRule.setContent {
ChatInputBar(
inputFieldValue = TextFieldValue(text),
onInputChange = {},
onSend = onSend,
onBusySend = onBusySend,
onMicTap = {},
isListening = false,
isAgentTyping = isMainTurnBusy,
isMainTurnBusy = isMainTurnBusy,
isConnected = isConnected,
commandCatalog = CommandCatalog(),
isSessionReady = true,
pendingAttachments = attachments,
pendingReasoningLevel = pendingReasoningLevel,
)
}
}
@Test
fun idleDraft_showsRegularSendWithoutBusyActions() {
setContextualComposer(text = "hello", isMainTurnBusy = false)
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
}
@Test
fun busyBlankDraft_hidesBusyActions() {
setContextualComposer(text = "", isMainTurnBusy = true)
composeTestRule.onNodeWithTag("send_button").assertDoesNotExist()
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
}
@Test
fun busyTextDraft_showsSendAndQueueAndRoutesCallbacks() {
var regularSends = 0
val selected = mutableListOf<BusySendMode>()
setContextualComposer(
text = "change course",
isMainTurnBusy = true,
onSend = { regularSends++ },
onBusySend = selected::add,
)
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed().performClick()
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed().performClick()
composeTestRule.runOnIdle {
assertEquals(1, regularSends)
assertEquals(listOf(BusySendMode.QUEUE), selected)
}
}
@Test
fun busyAttachmentOnly_showsSendAndQueue() {
val attachment = Attachment("content://document/1", "notes.txt", "text/plain")
setContextualComposer(text = "", isMainTurnBusy = true, attachments = listOf(attachment))
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
}
@Test
fun busyDisconnectedDraft_disablesSendAndQueue() {
setContextualComposer(text = "hello", isMainTurnBusy = true, isConnected = false)
composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled()
composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled()
}
@Test
fun busyPendingReasoning_disablesSendAndQueue() {
setContextualComposer(text = "hello", isMainTurnBusy = true, pendingReasoningLevel = "high")
composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled()
composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled()
}
@Test
fun busySlashDraft_usesRegularSendWithoutBusyActions() {
var regularSends = 0
setContextualComposer(
text = "/stop",
isMainTurnBusy = true,
onSend = { regularSends++ },
)
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
composeTestRule.onNodeWithTag("send_button").performClick()
composeTestRule.runOnIdle { assertEquals(1, regularSends) }
}
@Test
fun busyBecomesIdle_hidesQueueAndPreservesTypedDraft() {
val busy = mutableStateOf(true)
composeTestRule.setContent {
var input by remember { mutableStateOf(TextFieldValue("draft in progress")) }
ChatInputBar(
inputFieldValue = input,
onInputChange = { input = it },
onSend = {},
onBusySend = {},
onMicTap = {},
isListening = false,
isAgentTyping = busy.value,
isMainTurnBusy = busy.value,
isConnected = true,
commandCatalog = CommandCatalog(),
isSessionReady = true,
)
}
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
composeTestRule.runOnUiThread { busy.value = false }
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
composeTestRule.onNodeWithTag("chat_input").assertTextEquals("draft in progress")
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
}
@Test
fun busyDraft_narrowWidthKeepsSendAndQueueAccessible() {
composeTestRule.setContent {
Box(Modifier.width(220.dp)) {
ChatInputBar(
inputFieldValue = TextFieldValue("change course"),
onInputChange = {},
onSend = {},
onBusySend = {},
onMicTap = {},
isListening = false,
isAgentTyping = true,
isMainTurnBusy = true,
isConnected = true,
commandCatalog = CommandCatalog(),
isSessionReady = true,
)
}
}
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
}
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
private fun setComposer(
reasoningLevel: String? = "medium",
@@ -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<String, Map<String, String>, 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))
@@ -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<ComponentActivity>()
private fun row(state: PendingSendState) =
PendingSend("receipt", "scope", "session", "My message", mode = BusySendMode.CORRECT, state = state)
@Test
fun ordinarySendNeverShowsDeliveryPanel() {
val state = mutableStateOf(PendingSendState.SENDING)
compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) }
compose.onNodeWithText("My message").assertDoesNotExist()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_sends)).assertDoesNotExist()
compose.runOnIdle { state.value = PendingSendState.ACCEPTED }
compose.onNodeWithText("My message").assertDoesNotExist()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_accepted)).assertDoesNotExist()
}
@Test
fun queueDisappearsWhenDispatchedWithoutAwaitingLabel() {
val state = mutableStateOf(PendingSendState.QUEUED)
compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) }
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_queued)).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertDoesNotExist()
compose.runOnIdle { state.value = PendingSendState.SENDING }
compose.onNodeWithText("My message").assertDoesNotExist()
compose.runOnIdle { state.value = PendingSendState.ACCEPTED }
compose.onNodeWithText("My message").assertDoesNotExist()
}
@Test
fun failedAndUncertainDeliveryRemainRecoverable() {
val state = mutableStateOf(PendingSendState.REJECTED)
var retried: String? = null
compose.setContent {
PendingSendPanel(
listOf(row(state.value)),
mainTurnBusy = false,
onSendNow = { retried = it },
)
}
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_rejected)).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).performClick()
compose.runOnIdle {
assertEquals("receipt", retried)
state.value = PendingSendState.UNKNOWN
}
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_unknown)).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertIsDisplayed()
}
@Test
fun busyPanelWarnsAndLabelsEveryRecoverableActionAsStopAndSend() {
val state = mutableStateOf(PendingSendState.QUEUED)
compose.setContent {
PendingSendPanel(
sends = listOf(row(state.value)),
mainTurnBusy = true,
onSendNow = {},
)
}
listOf(
PendingSendState.QUEUED,
PendingSendState.PARKED,
PendingSendState.REJECTED,
PendingSendState.UNKNOWN,
).forEach { pendingState ->
compose.runOnIdle { state.value = pendingState }
compose
.onNodeWithText(
compose.activity.getString(R.string.chat_pending_send_now_warning),
).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_busy_stop_and_send)).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).assertDoesNotExist()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_again)).assertDoesNotExist()
}
}
}
@@ -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<ComponentActivity>()
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)
}
}
}
@@ -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 = {},
)
}
}
}
}
@@ -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
@@ -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,
@@ -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)
@@ -13,6 +13,7 @@ data class ConnectionOperationSnapshot(
val settled: Boolean,
val settledBy: String?,
val targets: List<ConnectionOperationTarget>,
val accountOwned: Boolean = false,
)
data class ConnectionOperationTarget(
@@ -29,6 +30,8 @@ data class ConnectionOperationTarget(
val requiredEnv: List<ConnectionEnvField>,
val tools: List<String>,
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<String>,
val repo: String?,
val sha: String?,
val subdir: String?,
val scan: ConnectionCatalogScan?,
val requirements: List<String>,
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,
@@ -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<String> = emptyList(),
)
data class ConnectorPolicy(
val revision: String,
val mode: String,
val connectors: List<String>,
val disabledConnectors: List<String>,
val tools: Map<String, List<String>>,
val enabledTags: List<String> = emptyList(),
val disabledTags: List<String> = emptyList(),
val member: ConnectorPolicy? = null,
val inherited: List<ConnectorPolicy> = 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<out T> {
data class Success<T>(
val value: T,
) : AccountConnectorResult<T>
data class Failure(
val error: ConnectorError,
) : AccountConnectorResult<Nothing>
}
@@ -20,7 +20,12 @@ data class McpServer(
val error: String? = null,
val auth: String? = null,
val tools: List<String>? = 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(
@@ -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<String, List<String>>? = null,
@SerialName("delta_lines") val deltaLines: List<String>? = null,
val error: String? = null,
) {
val capabilityDelta: List<String>
get() =
deltaLines?.takeIf { it.isNotEmpty() }
?: delta.orEmpty().map { (surface, added) -> "$surface: ${added.joinToString() }" }
}
@Serializable
data class AgentPluginInstallBody(
val identifier: String = "",
@@ -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<SessionMessage>? = 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,
)
@@ -63,6 +63,7 @@ data class SessionListResponse(
val total: Int = 0,
val limit: Int = 0,
val offset: Int = 0,
val storage: Map<String, String> = emptyMap(),
)
@Serializable
@@ -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<String>
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+")
@@ -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,
)
@@ -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()
@@ -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<Unit>
@Body body: com.m57.hermescontrol.data.model.PluginUpdateRequest,
): Response<com.m57.hermescontrol.data.model.PluginUpdateResult>
@POST("api/dashboard/agent-plugins/{name}/enable")
suspend fun enablePlugin(
@@ -1042,4 +1045,10 @@ interface HermesApiService : KanbanApiService {
suspend fun transcribeAudio(
@Body body: AudioTranscriptionRequest,
): Response<AudioTranscriptionResponse>
@POST("api/audio/speak")
suspend fun speakText(
@Body request: TtsSpeakRequest,
@Query("profile") profile: String? = null,
): Response<TtsSpeakResponse>
}
@@ -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()
}
@@ -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<String, String>,
) {
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<Pair<String, String>>): CustomHeaders {
val result = linkedMapOf<String, String>()
val names = mutableSetOf<String>()
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<ScopedServerHeaders> = 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<Map<String, Map<String, String>>>(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())
}
}
@@ -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<AudioTranscriptionResponse> =
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
@@ -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<List<ConnectorCatalogEntry>>
suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult
suspend fun connect(
slugs: List<String>,
reconnect: Boolean = false,
): AccountConnectorResult<com.m57.hermescontrol.data.model.ConnectionOperationSnapshot>
suspend fun accounts(): AccountConnectorResult<List<ConnectorAccount>>
suspend fun remove(connectionId: String): AccountConnectorResult<Unit>
suspend fun tools(
slug: String,
refresh: Boolean = false,
): AccountConnectorResult<List<ConnectorTool>>
suspend fun policy(): AccountConnectorResult<ConnectorPolicy>
suspend fun setConnectorEnabled(
slug: String,
enabled: Boolean,
expectedRevision: String,
): AccountConnectorResult<ConnectorPolicy>
suspend fun setDisabledTools(
slug: String,
disabled: List<String>,
expectedRevision: String,
): AccountConnectorResult<ConnectorPolicy>
suspend fun operationRequest(
method: String,
params: Map<String, Any>,
): Any?
suspend fun operationStatus(
opId: String,
): AccountConnectorResult<com.m57.hermescontrol.data.model.ConnectionOperationSnapshot>
companion object : AccountConnectorRepository by HermesAccountConnectorRepository()
}
class HermesAccountConnectorRepository(
private val rpc: suspend (String, Map<String, Any>) -> 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<String, Any> = emptyMap(),
) = rpc(method, params)
private suspend fun <T> guarded(block: suspend () -> T): AccountConnectorResult<T> =
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<String, Any> = emptyMap()): Map<String, Any> =
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<String>,
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<String, Any>,
): 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<String, Any?>) +
("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<String, Any> {
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<String>,
expectedRevision: String,
) = setPolicy(
expectedRevision,
mapOf(
"type" to "tools",
"connector" to slug,
"disabled_tools" to disabled,
),
)
private suspend fun setPolicy(
expectedRevision: String,
change: Map<String, Any>,
) = 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(),
)
}
@@ -73,7 +73,6 @@ object CommandBlocklist {
"/image",
"/quit",
// TUI-only extras (meaningless outside the TUI)
"/compact",
"/logs",
"/mouse",
)
@@ -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<String, Any?>,
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<String>() ?: 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<String> =
(this[key] as? List<*>)
?.filterIsInstance<String>()
?.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
@@ -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<String, String> = 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,
)
@@ -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<String>? = 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<PluginServerRow> = emptyList(),
@SerialName("settings_schema") val settingsSchema: List<PluginSettingField>? = 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<String>? = null,
val plugin: AgentPluginRow? = null,
val error: String? = null,
)
@@ -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<String, Any?>) -> 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<AgentPluginRow> {
val params = mutableMapOf<String, Any?>("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<List<AgentPluginRow>>(pluginsElement)
}
suspend fun saveSettings(
key: String,
values: Map<String, JsonElement>,
profile: String? = null,
): PluginsManageSettingsResult {
val params =
mutableMapOf<String, Any?>(
"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<PluginsManageSettingsResult>(result.toJsonElement())
}
}
@@ -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
@@ -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
@@ -11,6 +11,21 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
internal fun buildLegacyClarifyBatchResponses(
questions: List<ClarifyQuestionUi>,
answers: Map<String, String>,
lockedAnswers: Map<String, String>,
): List<Pair<String, String>> =
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<ChatUiState>,
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<String, Any>(
"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
@@ -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<ConnectionOperationUiState> = _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<String, String> =
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"
}
}
@@ -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. */
@@ -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"]),
)
}
@@ -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<ChatMessage>,
pendingSends: List<PendingSend>,
): List<ChatMessage> {
val waitingIds =
pendingSends
.filter { it.state == PendingSendState.QUEUED || it.state == PendingSendState.PARKED }
.map { it.id }
.toSet()
if (waitingIds.isEmpty()) return messages
return messages.filterNot {
it.role == MessageRole.USER && it.id in waitingIds && it.canonicalRestId == null
}
}
@@ -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,
@@ -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
@@ -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
}
}
}
@@ -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<ChatMessage>(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,
@@ -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<String, Any>,
)
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>()
private val outgoingRequestById = ConcurrentHashMap<String, String>()
private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>()
private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>()
@@ -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<String, Any?> ?: 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<String, Any> {
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<SessionCompressResponse>(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<String>()
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<String, Any?>
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 ────────────────────────────────────────────────────────────
@@ -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<ReducerEffect>()
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,
@@ -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()
}
@@ -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,
@@ -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",
@@ -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
}
}
@@ -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<Float> = 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.
@@ -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<Float>,
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,
)
}
}
@@ -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
@@ -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"),
)
}
@@ -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"),
)
@@ -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<PendingSend>,
mainTurnBusy: Boolean,
onSendNow: (String) -> Unit,
) {
// Delivery receipts stay internal; only queued work and recovery need UI.
val visibleSends = sends.filter { it.state != PendingSendState.SENDING && it.state != PendingSendState.ACCEPTED }
if (visibleSends.isEmpty()) return
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(stringResource(R.string.chat_pending_sends), style = MaterialTheme.typography.titleSmall)
if (mainTurnBusy || visibleSends.any { it.state == PendingSendState.UNKNOWN }) {
Text(
stringResource(R.string.chat_pending_send_now_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LazyColumn(modifier = Modifier.heightIn(max = 170.dp)) {
items(visibleSends, key = { it.id }) { send ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(modifier = Modifier.weight(1f)) {
Text(
send.text.ifBlank { stringResource(R.string.chat_pending_attachments) },
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text =
when (send.state) {
PendingSendState.QUEUED -> stringResource(R.string.chat_pending_queued)
PendingSendState.PARKED -> stringResource(R.string.chat_pending_parked)
PendingSendState.SENDING -> stringResource(R.string.chat_pending_sending)
PendingSendState.ACCEPTED -> stringResource(R.string.chat_pending_accepted)
PendingSendState.UNKNOWN -> stringResource(R.string.chat_pending_unknown)
PendingSendState.REJECTED -> stringResource(R.string.chat_pending_rejected)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (send.state in
setOf(
PendingSendState.QUEUED,
PendingSendState.PARKED,
PendingSendState.UNKNOWN,
PendingSendState.REJECTED,
PendingSendState.ACCEPTED,
)
) {
TextButton(onClick = { onSendNow(send.id) }) {
Text(
stringResource(
if (mainTurnBusy) {
R.string.chat_busy_stop_and_send
} else if (send.state in
setOf(
PendingSendState.UNKNOWN,
PendingSendState.ACCEPTED,
)
) {
R.string.chat_pending_send_again
} else {
R.string.chat_pending_send_now
},
),
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,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
}
@@ -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<String?>(null)
val speakingId: StateFlow<String?> = _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
}
}
@@ -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<String>()
// 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
}
}
@@ -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<Float>,
slideProgress: State<Float>,
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<Float>() }
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 {
@@ -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 =
@@ -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<String, @Composable () -> 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,
@@ -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,
)
@@ -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)
@@ -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 ""
}
@@ -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<Int?>(null) }
var deleting by remember { mutableStateOf<Int?>(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)) }
},
)
}
}
@@ -131,7 +131,7 @@ fun WebhookSubscription.toDetailRows(): List<DetailRow> =
* 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
@@ -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<ConnectorAccount?>(null) }
var browserOperation by remember(state.scope) { mutableStateOf<String?>(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)) }
},
)
}
}
@@ -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<ConnectorCatalogEntry> = emptyList(),
val connectorStates: List<ConnectorItem> = emptyList(),
val accounts: List<ConnectorAccount> = emptyList(),
val tools: Map<String, List<ConnectorTool>> = emptyMap(),
val toolsLoading: Set<String> = emptySet(),
val policy: ConnectorPolicy? = null,
val busy: Boolean = false,
val scope: DataScope? = null,
)
class AccountConnectorsViewModel(
private val repository: AccountConnectorRepository = AccountConnectorRepository,
private val scopes: StateFlow<DataScope?> = AuthManager.dataScopeFlow,
private val currentScope: () -> DataScope? = AuthManager::currentDataScope,
events: Flow<WsEvent> = HermesWsClient.events,
) : ViewModel() {
private val _state = MutableStateFlow(AccountConnectorsState())
val state: StateFlow<AccountConnectorsState> = _state.asStateFlow()
private var generation = 0L
private var loadJob: Job? = null
private val scopedJobs = mutableListOf<Job>()
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<String>,
) = updatePolicy {
repository.setDisabledTools(
slug,
disabled,
_state.value.policy
?.member
?.revision
.orEmpty(),
)
}
private fun updatePolicy(action: suspend () -> AccountConnectorResult<ConnectorPolicy>) =
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<String, String>,
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 <T> AccountConnectorResult<T>.valueOrNull(): T? = (this as? AccountConnectorResult.Success)?.value
private fun AccountConnectorResult<*>.errorMessage(): String? =
(this as? AccountConnectorResult.Failure)?.error?.message
@@ -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<McpServersUiState> = _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,
@@ -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))
@@ -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()
},
)
}
@@ -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<String, String> = 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<String, String>()
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<String, JsonElement>()
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<String, String>()
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) }
}
@@ -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<String, String>,
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
},
)
}
}
}
}
}
@@ -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,
@@ -81,6 +81,7 @@ data class SessionsUiState(
val total: Int = 0,
val hasMore: Boolean = false,
val errorMessage: String? = null,
val corruptStorageProfiles: Set<String> = 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 =
@@ -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),
@@ -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) {
@@ -175,6 +175,7 @@ internal fun ConnectionSection(
onTokenChange = viewModel::onDialogProfileTokenChange,
onSave = viewModel::saveProfileFromDialog,
onDismiss = viewModel::closeProfileDialog,
onHeadersSaved = viewModel::onCustomHeadersSaved,
)
}
@@ -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,
+18
View File
@@ -1557,6 +1557,24 @@
<string name="connection_setup_required">%1$s مطلوب</string>
<string name="connection_setup_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string>
<string name="connection_setup_pending">بانتظار Hermes…</string>
<string name="connection_catalog_title">تثبيت %1$s</string>
<string name="connection_catalog_kind_plugin">إضافة</string>
<string name="connection_catalog_kind_skill">مهارة</string>
<string name="connection_catalog_tier_official">رسمي</string>
<string name="connection_catalog_tier_community">مجتمعي</string>
<string name="connection_catalog_source">المصدر: %1$s</string>
<string name="connection_catalog_pin">الإيداع المثبّت: %1$s</string>
<string name="connection_catalog_platforms">المنصات: %1$s</string>
<string name="connection_catalog_requirements">المتطلبات: %1$s</string>
<string name="connection_catalog_target_profile">يُثبَّت في الملف الشخصي: %1$s</string>
<string name="connection_catalog_scan_passed">اجتاز الفحص الأمني</string>
<string name="connection_catalog_scan_warnings">وجد الفحص الأمني تحذيرات</string>
<string name="connection_catalog_scan_failed">فشل الفحص الأمني</string>
<string name="connection_catalog_scan_unreported">لم يُبلَّغ عن فحص أمني</string>
<string name="connection_catalog_install">تثبيت</string>
<string name="connection_catalog_retry">حاول مجددًا</string>
<string name="connection_catalog_installing">جارٍ التثبيت…</string>
<string name="connection_catalog_skill_ready">المهارة متاحة: %1$s</string>
<string name="chat_busy_mode_title">أثناء عمل الوكيل</string>
<string name="chat_busy_mode_desc">اختر كيفية وصول الرسالة الجديدة إلى المحادثة الحالية. تُرسل الرسائل بشكل عادي عند الخمول.</string>
+18
View File
@@ -1213,6 +1213,24 @@
<string name="connection_setup_required">%1$s은(는) 필수입니다</string>
<string name="connection_setup_request_failed">설정 응답을 보낼 수 없습니다. 다시 시도하세요.</string>
<string name="connection_setup_pending">Hermes를 기다리는 중…</string>
<string name="connection_catalog_title">%1$s 설치</string>
<string name="connection_catalog_kind_plugin">플러그인</string>
<string name="connection_catalog_kind_skill">스킬</string>
<string name="connection_catalog_tier_official">공식</string>
<string name="connection_catalog_tier_community">커뮤니티</string>
<string name="connection_catalog_source">소스: %1$s</string>
<string name="connection_catalog_pin">고정 커밋: %1$s</string>
<string name="connection_catalog_platforms">플랫폼: %1$s</string>
<string name="connection_catalog_requirements">요구 사항: %1$s</string>
<string name="connection_catalog_target_profile">설치 대상 프로필: %1$s</string>
<string name="connection_catalog_scan_passed">보안 검사 통과</string>
<string name="connection_catalog_scan_warnings">보안 검사에서 경고 발견</string>
<string name="connection_catalog_scan_failed">보안 검사 실패</string>
<string name="connection_catalog_scan_unreported">보고된 보안 검사 없음</string>
<string name="connection_catalog_install">설치</string>
<string name="connection_catalog_retry">다시 시도</string>
<string name="connection_catalog_installing">설치 중…</string>
<string name="connection_catalog_skill_ready">사용 가능한 스킬: %1$s</string>
<!-- Kanban task detail -->
<string name="kanban_specify_task">작업 지정</string>
+18
View File
@@ -1509,6 +1509,24 @@
<string name="connection_setup_required">%1$s为必填项</string>
<string name="connection_setup_request_failed">无法发送设置响应,请重试。</string>
<string name="connection_setup_pending">正在等待 Hermes…</string>
<string name="connection_catalog_title">安装 %1$s</string>
<string name="connection_catalog_kind_plugin">插件</string>
<string name="connection_catalog_kind_skill">技能</string>
<string name="connection_catalog_tier_official">官方</string>
<string name="connection_catalog_tier_community">社区</string>
<string name="connection_catalog_source">来源:%1$s</string>
<string name="connection_catalog_pin">固定提交:%1$s</string>
<string name="connection_catalog_platforms">平台:%1$s</string>
<string name="connection_catalog_requirements">要求:%1$s</string>
<string name="connection_catalog_target_profile">安装到配置文件:%1$s</string>
<string name="connection_catalog_scan_passed">安全扫描已通过</string>
<string name="connection_catalog_scan_warnings">安全扫描发现警告</string>
<string name="connection_catalog_scan_failed">安全扫描失败</string>
<string name="connection_catalog_scan_unreported">未报告安全扫描</string>
<string name="connection_catalog_install">安装</string>
<string name="connection_catalog_retry">重试</string>
<string name="connection_catalog_installing">正在安装…</string>
<string name="connection_catalog_skill_ready">可用技能:%1$s</string>
<string name="chat_busy_mode_title">智能体工作时</string>
<string name="chat_busy_mode_desc">选择新消息如何送达当前聊天。空闲时正常发送。</string>
<string name="chat_busy_correct">修正当前轮次</string>
+82 -1
View File
@@ -4,8 +4,12 @@
<!-- Chat -->
<string name="chat_send_desc">Send</string>
<string name="chat_mic_listening">Listening…</string>
<string name="chat_voice_cancel">Cancel</string>
<string name="chat_voice_lock_hint">Slide up to lock recording</string>
<string name="chat_voice_record_failed">Could not start voice recording</string>
<string name="chat_voice_slide_to_cancel">Slide to cancel</string>
<string name="chat_voice_send_desc">Send voice note</string>
<string name="chat_voice_slide_to_cancel_action">cancel</string>
<string name="chat_voice_slide_to_cancel_prefix">Slide to</string>
<string name="chat_voice_stop_generating">Stop generating</string>
<string name="stt_permission_denied">Microphone permission is required for voice input</string>
<string name="stt_not_available">Speech recognition is not available on this device</string>
@@ -83,6 +87,7 @@
<string name="screen_cron">Cron</string>
<string name="screen_system">System</string>
<string name="screen_settings">Settings</string>
<string name="screen_account_connectors">Connectors</string>
<string name="screen_profiles">Profiles</string>
<string name="screen_bots">Bots</string>
<string name="bots_active_now">Active Now</string>
@@ -232,6 +237,11 @@
<string name="content_desc_scroll_to_bottom">Scroll to bottom</string>
<string name="content_desc_copy">Copy</string>
<string name="content_desc_copied">Copied</string>
<string name="content_desc_speak">Read aloud</string>
<string name="content_desc_stop_speaking">Stop speaking</string>
<string name="tts_error_network">Could not reach the Hermes server for speech</string>
<string name="tts_error_synthesis">Could not synthesize the message audio</string>
<string name="tts_error_playback">Could not play the message audio</string>
<string name="content_desc_edit">Edit</string>
<string name="chat_msg_tokens">%1$s tok</string>
<string name="chat_msg_tps">%1$s t/s</string>
@@ -244,6 +254,17 @@
<!-- Settings Screen -->
<string name="settings_sec_connection">Connection</string>
<string name="account_connectors_title">Account connectors</string>
<string name="account_connectors_summary">Manage account authorizations and tool access without a chat session.</string>
<string name="account_connectors_accounts">Authorized accounts</string>
<string name="account_connectors_connect">Connect</string>
<string name="account_connectors_tools">Tools</string>
<string name="account_connectors_enabled">Enabled</string>
<string name="account_connectors_not_connected">Not connected</string>
<string name="account_connectors_no_accounts">No authorized connector accounts.</string>
<string name="account_connectors_remove">Remove</string>
<string name="account_connectors_remove_confirm_title">Remove connector account?</string>
<string name="account_connectors_remove_confirm_message">Remove %1$s? You may need to authorize it again.</string>
<string name="settings_field_token">Auth Token</string>
<string name="settings_keep_connected_in_background_title">Stay connected in background</string>
<string name="settings_keep_connected_in_background_desc">Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery.</string>
@@ -1047,6 +1068,7 @@
<string name="plugins_no_desc">No description provided.</string>
<string name="plugins_action_update">Update</string>
<string name="plugins_action_uninstall">Uninstall</string>
<string name="plugins_action_settings">Settings</string>
<string name="plugins_action_enable">Enable</string>
<string name="plugins_action_install">Install</string>
<string name="plugins_action_remove">Remove</string>
@@ -1062,6 +1084,25 @@
<string name="plugins_remove_confirm_title">Remove Plugin</string>
<string name="plugins_remove_confirm_text">This will remove the "%1$s" plugin from your agent.</string>
<string name="plugins_auth_required">Auth Required</string>
<string name="plugins_settings_loading">Loading plugin settings…</string>
<string name="plugins_settings_title">Plugin Settings</string>
<string name="plugins_settings_save">Save Settings</string>
<string name="plugins_settings_saved">Plugin settings saved</string>
<string name="plugins_settings_save_failed">Failed to save settings: %1$s</string>
<string name="plugins_diagnostics_heading">Diagnostics</string>
<string name="plugins_diagnostics_empty">No diagnostics reported</string>
<string name="plugins_settings_heading">Settings</string>
<string name="plugins_settings_secret_set">set</string>
<string name="plugins_settings_secret_not_set">not set</string>
<string name="plugins_settings_secret_hint">Stored in .env as %1$s (leave blank to keep)</string>
<string name="plugins_settings_empty">No configurable settings</string>
<string name="plugins_server_state_connected">Connected</string>
<string name="plugins_server_state_app_not_running">App not running</string>
<string name="plugins_server_state_endpoint_unavailable">Endpoint unavailable</string>
<string name="plugins_server_state_no_interactive_session">No interactive session</string>
<string name="plugins_server_state_version_too_old">Version too old</string>
<string name="plugins_server_state_missing_app">App missing</string>
<string name="plugins_server_state_unknown">Status unknown</string>
<string name="plugins_auth_command_hint">Auth command: %1$s</string>
<string name="plugins_dashboard_slots">Dashboard slots: %1$s</string>
<string name="plugins_orphan_heading">Orphaned Dashboard Plugins</string>
@@ -1115,6 +1156,8 @@
<!-- McpServers Screen -->
<string name="mcp_servers_label_transport">Transport: %1$s</string>
<string name="mcp_servers_from_plugin">From plugin %1$s · Read-only</string>
<string name="mcp_servers_plugin_managed">Plugin-managed · Read-only</string>
<string name="mcp_servers_label_command">Command:</string>
<string name="mcp_servers_action_test">Test</string>
<string name="mcp_servers_action_test_all">Test All</string>
@@ -1736,6 +1779,24 @@
<string name="connection_setup_required">%1$s is required</string>
<string name="connection_setup_request_failed">Couldn’t send the setup response. Try again.</string>
<string name="connection_setup_pending">Waiting for Hermes…</string>
<string name="connection_catalog_title">Install %1$s</string>
<string name="connection_catalog_kind_plugin">Plugin</string>
<string name="connection_catalog_kind_skill">Skill</string>
<string name="connection_catalog_tier_official">Official</string>
<string name="connection_catalog_tier_community">Community</string>
<string name="connection_catalog_source">Source: %1$s</string>
<string name="connection_catalog_pin">Pinned commit: %1$s</string>
<string name="connection_catalog_platforms">Platforms: %1$s</string>
<string name="connection_catalog_requirements">Requires: %1$s</string>
<string name="connection_catalog_target_profile">Installs into profile: %1$s</string>
<string name="connection_catalog_scan_passed">Security scan passed</string>
<string name="connection_catalog_scan_warnings">Security scan found warnings</string>
<string name="connection_catalog_scan_failed">Security scan failed</string>
<string name="connection_catalog_scan_unreported">No security scan reported</string>
<string name="connection_catalog_install">Install</string>
<string name="connection_catalog_retry">Try again</string>
<string name="connection_catalog_installing">Installing…</string>
<string name="connection_catalog_skill_ready">Skill available: %1$s</string>
<!-- Kanban task detail -->
<string name="kanban_specify_task">Specify task</string>
@@ -1763,4 +1824,24 @@
<string name="kanban_attachment_error">Attachment error: %1$s</string>
<string name="kanban_active_workers">Active workers</string>
<string name="kanban_no_active_workers">No active workers</string>
<string name="custom_headers_title">Custom headers</string>
<string name="custom_headers_description">Saved securely for this server URL. Use Cloudflare Access service-token headers here. Hermes login is still required.</string>
<string name="custom_headers_name">Header name</string>
<string name="custom_headers_value">Header value</string>
<string name="custom_headers_add">Add header</string>
<string name="custom_headers_cloudflare">Add Cloudflare headers</string>
<string name="custom_headers_remove">Remove header</string>
<string name="custom_headers_remove_confirm">Remove this header? Save your changes to apply the removal.</string>
<string name="custom_headers_show">Show header values</string>
<string name="custom_headers_hide">Hide header values</string>
<string name="custom_headers_invalid">Use unique HTTP header names and single-line ASCII values. Authorization, Cookie, Host, and connection headers are managed by the app.</string>
<string name="custom_headers_save_failed">Could not save custom headers. Try again.</string>
<string name="plugins_update_consent_title">Review new capabilities</string>
<string name="plugins_update_consent_text">Updating %1$s requires the additional capabilities below. Nothing has been installed yet. Accept these capabilities and update?</string>
<string name="plugins_update_consent_missing">The server did not provide capability details.</string>
<string name="plugins_update_accept">Accept and update</string>
<string name="sessions_storage_corrupt">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.</string>
<string name="account_connectors_reconnect">Reconnect</string>
<string name="account_connectors_no_tools">No tools available</string>
<string name="account_connectors_policy_locked">Read-only policy restrictions</string>
</resources>
@@ -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)
@@ -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)
@@ -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<TtsSpeakResponse>(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<TtsSpeakResponse>(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<TtsSpeakResponse>(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<TtsSpeakResponse>(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<TtsSpeakResponse>(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<TtsSpeakResponse>(rawJson)
assertFalse(response.ok)
assertNull(response.data_url)
assertNull(response.mime_type)
assertNull(response.provider)
}
}
@@ -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<SharedPreferences>()
private val editor = mockk<SharedPreferences.Editor>()
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"))
}
}
@@ -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<HermesApiService>()
@@ -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 {
@@ -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<Pair<String, Map<String, Any>>>()
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<Pair<String, Map<String, Any>>>()
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<Pair<String, Map<String, Any>>>()
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<Pair<String, Map<String, Any>>>()
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<String>(),
),
"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"))),
),
),
)
}
@@ -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<String, Any?> =
mapOf(
"op_id" to "op-1",
@@ -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"))
@@ -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)
@@ -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<PluginSettingField>(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<PluginSettingField>(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<PluginSettingField>(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<PluginSettingField>(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<PluginSettingField>(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<PluginSettingField>(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<PluginSettingField>(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<PluginSettingField>(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<PluginServerState>("\"$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<PluginServerRow>(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<AgentPluginRow>(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<AgentPluginRow>(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<PluginsManageSettingsResult>(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<PluginsManageSettingsResult>(jsonString)
assertFalse(result.ok)
assertEquals("camofox", result.name)
assertEquals("Validation failed: port must be an integer", result.error)
assertNull(result.written)
assertNull(result.plugin)
}
}
@@ -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<String, Any?> = 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<String, Any?> = 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<String, Any?> = 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<String, JsonElement> =
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<String, Any?> = 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<String, JsonElement> =
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
}
}
}
@@ -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
@@ -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<WsEvent>(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<String>()) } returns 0
every { android.util.Log.e(any(), any(), any()) } returns 0
mockkObject(AuthManager)
every { AuthManager.getPinnedModels() } returns emptyList()
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
every { AuthManager.activeProfileId } returns MutableStateFlow<String?>(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<String>(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<Any?>(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<String>()
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
app = mockk(relaxed = true)
fakeRepo = FakeChatPersistenceRepository()
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
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<String>()
every {
HermesWsClient.request(capture(methodCalls), any(), any())
} answers {
CompletableDeferred<Any?>(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<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
CompletableDeferred<Any?>(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<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
CompletableDeferred<Any?>(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<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
CompletableDeferred<Any?>(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<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
CompletableDeferred<Any?>(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<Any?>(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<Any?>(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<String>()
every {
HermesWsClient.request(capture(methodCalls), any(), any())
} answers {
val method = arg<String>(0)
if (method == WsMethods.SESSION_COMPRESS) {
val responseJson =
buildJsonObject {
put("status", JsonPrimitive("ok"))
put("message", JsonPrimitive("Context compressed."))
}
CompletableDeferred<Any?>(responseJson)
} else {
CompletableDeferred<Any?>(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<Any?>()
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<Any?>()
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<Any?>()
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<Any?>()
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)
}
}
@@ -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<String, Any>
@@ -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 {
@@ -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<String>? = 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)
}
}
@@ -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)
@@ -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<String, Any?>,
) = ChatWsEventReducer.reduce(
state = ChatUiState(currentSessionId = "session-1"),
streamingState = StreamingState(),
event = WsEvent.MessageComplete(text, "session-1", rawPayload = mapOf("persisted_turn" to receipt)),
currentSessionId = "session-1",
)
}
@@ -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)
@@ -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))))
}
}
@@ -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<Pair<String, Map<String, Any>>>()
every { HermesWsClient.send(any(), any(), any()) } answers {
val id = "req-${captured.size + 1}"
captured.add(arg<String>(0) to (arg<Map<String, Any>>(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<Pair<String, Map<String, Any>>>()
every { HermesWsClient.send(any(), any(), any()) } answers {
val id = "req-${captured.size + 1}"
captured.add(arg<String>(0) to (arg<Map<String, Any>>(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<Pair<String, Map<String, Any>>>()
every { HermesWsClient.send(any(), any(), any()) } answers {
val id = "req-${captured.size + 1}"
captured.add(arg<String>(0) to (arg<Map<String, Any>>(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(
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more