release: promote dev to main for v2.0.0 (#1426)

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-10-01 11:19:09 +03:00
1 parent ed915d0816
commit 728daa9acd
315 files changed
+65173 -7909

No files matched your search

+17 -2
View File
@@ -85,18 +85,26 @@ android {
buildTypes {
debug {
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
buildConfigField("boolean", "ALLOW_CLEARTEXT", "true")
manifestPlaceholders["usesCleartextTraffic"] = "true"
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = signingConfigs["release"]
buildConfigField("boolean", "ALLOW_CLEARTEXT", "true")
manifestPlaceholders["usesCleartextTraffic"] = "true"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
@@ -309,6 +317,13 @@ tasks.named("check") {
dependsOn("checkColorLiterals")
}
tasks.withType<Test> {
val includeBenchmarks = providers.gradleProperty("includeBenchmarks").isPresent
tasks.withType<Test>().configureEach {
useJUnitPlatform()
maxParallelForks = 2
// Assertion-free timing probes are opt-in: ./gradlew testDebugUnitTest -PincludeBenchmarks
if (!includeBenchmarks) {
filter { excludeTestsMatching("*Benchmark") }
}
}
@@ -0,0 +1,296 @@
package com.m57.hermescontrol.data.ws
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okio.ByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
@RunWith(AndroidJUnit4::class)
class WsTransportDeviceTest {
private class FakeWebSocket(
private val request: Request,
var accept: Boolean = true,
var queueSizeValue: Long = 0L,
) : WebSocket {
val sentFrames = ConcurrentLinkedQueue<String>()
val attempts = AtomicInteger(0)
val closedCode = AtomicInteger(-1)
val closedReason =
java.util.concurrent.atomic
.AtomicReference<String?>(null)
val canceled = AtomicBoolean(false)
override fun request(): Request = request
override fun queueSize(): Long = queueSizeValue
override fun send(text: String): Boolean {
attempts.incrementAndGet()
if (!accept) return false
sentFrames.add(text)
return true
}
override fun send(bytes: ByteString): Boolean {
attempts.incrementAndGet()
if (!accept) return false
sentFrames.add(bytes.utf8())
return true
}
override fun close(
code: Int,
reason: String?,
): Boolean {
closedCode.set(code)
closedReason.set(reason)
return true
}
override fun cancel() {
canceled.set(true)
}
}
private class TestFixture {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val lock = Any()
val clock = AtomicLong(100L)
val dummyRequest: Request = Request.Builder().url("http://localhost/ws").build()
val openResponse: Response =
Response
.Builder()
.request(dummyRequest)
.protocol(Protocol.HTTP_1_1)
.code(101)
.message("Switching Protocols")
.build()
val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = false
}
val recordedEvents = ConcurrentLinkedQueue<WsEvent>()
lateinit var transport: WsTransport
lateinit var rpc: RpcChannel
lateinit var health: ConnectionHealth
lateinit var replay: ReplayTracker
init {
health =
ConnectionHealth(
scope = scope,
isConnected = { transport.isConnected },
pingRequest = { _ -> },
cancelSocket = { transport.cancelSocket() },
pendingReply = { transport.pendingReply },
nowMs = { clock.get() },
)
replay =
ReplayTracker(
scope = scope,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = { recordedEvents.add(it) },
)
rpc =
RpcChannel(
scope = scope,
json = json,
outboundLock = lock,
sendRequest = { method, params, onSent ->
transport.send(rpc.createRequest(method, params), onSent)
},
sendFrame = { transport.sendDirectFrame(it) },
removeQueuedMessage = { transport.removeQueuedMessage(it) },
onIdleCheck = { transport.disconnectIfIdleInBackground() },
onResult = { transport.onRpcResult(it) },
onError = { transport.onRpcError(it) },
)
transport =
WsTransport(
scope = scope,
outboundLock = lock,
reconnectPolicy = ReconnectPolicy(),
connectionHealth = health,
replayTracker = replay,
rpcChannel = rpc,
onRawMessage = { raw ->
rpc.handleIncomingFrame(raw, replay) { recordedEvents.add(it) }
},
)
}
fun close() {
try {
transport.disconnect(clearPendingMessages = true)
} finally {
scope.cancel()
}
}
}
@Test
fun transportRpcRoundTripWithStaleListenerIsolation() =
kotlinx.coroutines.runBlocking {
val fixture = TestFixture()
try {
val fakeWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L)
fixture.transport.connectionGenerationForTest.set(1)
fixture.transport.intentionalCloseForTest.set(false)
val listenerGen1 = fixture.transport.createListenerForTest(1)
listenerGen1.onOpen(fakeWs, fixture.openResponse)
assertTrue(fixture.transport.isConnected)
assertEquals(fakeWs, fixture.transport.webSocketForTest)
val deferred = fixture.rpc.request("gateway.ping", emptyMap())
val sentFrame = requireNotNull(fakeWs.sentFrames.poll())
val parsedSent = fixture.json.decodeFromString<JsonRpcRequest>(sentFrame)
val reqId = parsedSent.id
fixture.clock.set(200L)
val responseJson = """{"jsonrpc":"2.0","id":"$reqId","result":42.0}"""
listenerGen1.onMessage(fakeWs, responseJson)
val result = withTimeout(2_000L) { deferred.await() }
assertEquals(JsonPrimitive(42.0), result)
val event = requireNotNull(fixture.recordedEvents.poll())
assertTrue(event is WsEvent.RpcResult)
assertEquals(reqId, (event as WsEvent.RpcResult).id)
assertEquals(200L, fixture.health.lastPongTimestamp)
val staleListener = fixture.transport.createListenerForTest(0)
val staleWs = FakeWebSocket(fixture.dummyRequest, accept = true)
staleListener.onMessage(staleWs, """{"jsonrpc":"2.0","id":999,"result":99.0}""")
listenerGen1.onMessage(staleWs, """{"jsonrpc":"2.0","id":998,"result":88.0}""")
assertTrue(fixture.transport.isConnected)
assertEquals(fakeWs, fixture.transport.webSocketForTest)
assertEquals(200L, fixture.health.lastPongTimestamp)
assertTrue(fixture.recordedEvents.isEmpty())
} finally {
fixture.close()
}
}
@Test
fun rejectingWsQueuesAndFlushesOnNextAcceptSocket() =
kotlinx.coroutines.runBlocking {
val fixture = TestFixture()
try {
val rejectingWs = FakeWebSocket(fixture.dummyRequest, accept = false, queueSizeValue = 1L)
fixture.transport.connectionGenerationForTest.set(1)
fixture.transport.intentionalCloseForTest.set(false)
val listener1 = fixture.transport.createListenerForTest(1)
listener1.onOpen(rejectingWs, fixture.openResponse)
assertTrue(fixture.transport.isConnected)
var recordedId: String? = null
var callbackRanUnderLock = false
val req1 = fixture.rpc.createRequest("test.one", emptyMap())
val returnedId =
fixture.transport.send(
request = req1,
onSent = { id ->
callbackRanUnderLock = Thread.holdsLock(fixture.lock)
recordedId = id
},
)
assertEquals(returnedId, req1.id.toString())
assertEquals(returnedId, recordedId)
assertTrue(callbackRanUnderLock)
val req2 = fixture.rpc.createRequest("test.two", emptyMap())
fixture.transport.messageQueueForTest.add(fixture.rpc.encodeRequest(req2))
assertFalse(fixture.transport.isConnected)
assertEquals(ConnectionStatus.RECONNECTING, fixture.transport.connectionStatus.value)
assertNotNull(fixture.transport.outboundDrainJobForTest)
assertEquals(2, fixture.transport.messageQueueForTest.size)
val acceptWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L)
val listenerReplacement = fixture.transport.createListenerForTest(1)
listenerReplacement.onOpen(acceptWs, fixture.openResponse)
assertTrue(fixture.transport.isConnected)
assertEquals(ConnectionStatus.CONNECTED, fixture.transport.connectionStatus.value)
assertNull(fixture.transport.outboundDrainJobForTest)
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
val frameList = acceptWs.sentFrames.toList()
assertEquals(
listOf(fixture.rpc.encodeRequest(req1), fixture.rpc.encodeRequest(req2)),
frameList,
)
} finally {
fixture.close()
}
}
@Test
fun authClosingDoesNotPrematurelyDropPendingRpcAndClosedHaltsReconnect() =
kotlinx.coroutines.runBlocking {
val fixture = TestFixture()
try {
val fakeWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L)
fixture.transport.connectionGenerationForTest.set(1)
fixture.transport.intentionalCloseForTest.set(false)
val listener = fixture.transport.createListenerForTest(1)
listener.onOpen(fakeWs, fixture.openResponse)
assertTrue(fixture.transport.isConnected)
val pendingDeferred = fixture.rpc.request("gateway.ping", emptyMap())
assertTrue(fixture.rpc.hasPendingCalls())
listener.onClosing(fakeWs, 4401, "AUTH_EXPIRED")
assertTrue(fixture.transport.isConnected)
assertTrue(fixture.rpc.hasPendingCalls())
assertFalse(pendingDeferred.isCompleted)
listener.onClosed(fakeWs, 4401, "AUTH_EXPIRED")
assertFalse(fixture.transport.isConnected)
assertNull(fixture.transport.reconnectJobForTest)
assertTrue(fixture.rpc.hasPendingCalls())
assertFalse(pendingDeferred.isCompleted)
fixture.transport.disconnect(clearPendingMessages = true)
assertFalse(pendingDeferred.isCompleted)
pendingDeferred.cancel()
} finally {
fixture.close()
}
}
}
@@ -251,7 +251,15 @@ class ChatReadNotificationTest {
SessionMessage(id = 10, role = "assistant", content = JsonPrimitive("Done")),
SessionMessage(id = 52, role = "assistant", content = JsonPrimitive("Done")),
)
val messages = mapServerMessages(sessionId, history, 0, true, emptyList(), context = context)
val messages =
mapServerMessages(
sessionId,
history,
0,
true,
emptyList(),
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
)
assertEquals("Older duplicate must stay unmapped", null, messages[0].completionId)
assertEquals("Exact row must receive the completion id", "comp-exact-row", messages[1].completionId)
@@ -11,6 +11,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptUiState
import com.m57.hermescontrol.ui.common.ActionProgressController
import io.mockk.every
import io.mockk.mockk
@@ -61,6 +62,10 @@ class ChatScreenTest {
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
every { mockViewModel.transcriptState } returns
MutableStateFlow(
TranscriptUiState.resolve(uiState, ChatTimelineState(), StreamingState(), null, null),
).asStateFlow()
every { mockViewModel.connectionOperationState } returns
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
// ActionProgressDialog collects the controller's StateFlow — a relaxed
@@ -110,6 +115,10 @@ class ChatScreenTest {
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
every { mockViewModel.transcriptState } returns
MutableStateFlow(
TranscriptUiState.resolve(uiState, ChatTimelineState(), StreamingState(), null, null),
).asStateFlow()
every { mockViewModel.connectionOperationState } returns
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
every { mockViewModel.actionProgress } returns
@@ -0,0 +1,49 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class ChatSearchRenderingTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun reasoningMatchExpandsCollapsedCardAndShowsTheHit() {
composeTestRule.setContent {
MaterialTheme {
ReasoningCard(
reasoningText = "a hidden needle in the trace",
searchQuery = "needle",
isCurrentMatch = true,
)
}
}
composeTestRule.onNodeWithText("a hidden needle in the trace").assertIsDisplayed()
}
@Test
fun toolMatchRemainsVisibleInCollapsedHeader() {
composeTestRule.setContent {
MaterialTheme {
ToolBubble(
message = ChatMessage(role = MessageRole.TOOL, content = "opaque", toolName = "terminal"),
searchQuery = "terminal",
isCurrentMatch = true,
)
}
}
composeTestRule.onNodeWithText("terminal").assertIsDisplayed()
}
}
@@ -1,8 +1,10 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
@@ -30,4 +32,50 @@ class MarkdownHeadingRenderingTest {
composeTestRule.onNodeWithText("Set foo now").assertIsDisplayed()
composeTestRule.onNodeWithText("**Set `foo` now**").assertDoesNotExist()
}
@Test
fun quoteAndListContinuationRenderNestedCodeBlocks() {
composeTestRule.setContent {
MaterialTheme {
MarkdownText(
text =
"""
> # Quote heading
> ```kotlin
> val quoted = 1
> ```
- Item
```kotlin
val listed = 2
```
""".trimIndent(),
textColor = MaterialTheme.colorScheme.onSurface,
)
}
}
composeTestRule.onNodeWithText("Quote heading").assertIsDisplayed()
composeTestRule.onNodeWithText("Item").assertIsDisplayed()
composeTestRule.onAllNodesWithTag("code_block").assertCountEquals(2)
composeTestRule.onNodeWithText("val quoted = 1", substring = true).assertIsDisplayed()
composeTestRule.onNodeWithText("val listed = 2", substring = true).assertIsDisplayed()
}
@Test
fun streamingMarkdownRendersBlocksBeforeCompletion() {
composeTestRule.setContent {
MaterialTheme {
MarkdownText(
text = "## Live heading\n\n**ready**\n\n```kotlin\nval x = 1\n```",
textColor = MaterialTheme.colorScheme.onSurface,
isStreaming = true,
)
}
}
composeTestRule.onNodeWithText("Live heading").assertIsDisplayed()
composeTestRule.onNodeWithText("ready").assertIsDisplayed()
composeTestRule.onAllNodesWithTag("code_block").assertCountEquals(1)
}
}
@@ -1,11 +1,19 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -32,4 +40,26 @@ class MarkdownTableRenderingTest {
composeTestRule.onNodeWithText("**Name**").assertDoesNotExist()
composeTestRule.onNodeWithText("`code`").assertDoesNotExist()
}
@Test
fun columnsSizeToContentAndStayAlignedAcrossRows() {
composeTestRule.setContent {
MaterialTheme {
Box(Modifier.width(220.dp)) {
MarkdownText(
text = "| ID | Description |\n| --- | --- |\n| A | A considerably longer table value |",
textColor = MaterialTheme.colorScheme.onSurface,
)
}
}
}
val idHeader = composeTestRule.onNodeWithTag("markdown_table_header_0").getUnclippedBoundsInRoot()
val descHeader = composeTestRule.onNodeWithTag("markdown_table_header_1").getUnclippedBoundsInRoot()
val idCell = composeTestRule.onNodeWithTag("markdown_table_cell_0_0").getUnclippedBoundsInRoot()
val descCell = composeTestRule.onNodeWithTag("markdown_table_cell_0_1").getUnclippedBoundsInRoot()
assertTrue(descHeader.right - descHeader.left > idHeader.right - idHeader.left)
assertEquals(idHeader.right - idHeader.left, idCell.right - idCell.left)
assertEquals(descHeader.right - descHeader.left, descCell.right - descCell.left)
}
}
@@ -210,6 +210,59 @@ class MediaViewerDeviceTest {
assertEquals(expectedSize, actualSize)
}
@Test
fun imageUsesImageSurfaceAndStreamsSaveToDownloads() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val name = "viewer-image-${System.nanoTime()}.png"
val file = File(context.cacheDir, name)
val bitmap = android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888)
file.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
bitmap.recycle()
compose.setContent {
var visible by remember { mutableStateOf(true) }
HermesControlTheme {
if (visible) {
MediaViewerDialog(file.toURI().toString(), { visible = false }, name, "image/png")
}
}
}
awaitTag("media_image")
// Images never get the player surface.
assertTrue(compose.onAllNodesWithTag("media_play_pause_button").fetchSemanticsNodes().isEmpty())
compose.onNodeWithTag("media_save_button").assertIsDisplayed().performClick()
val resolver = context.contentResolver
var savedUri: Uri? = null
compose.waitUntil(15_000) {
resolver
.query(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.MediaColumns._ID),
"${MediaStore.MediaColumns.DISPLAY_NAME} = ?",
arrayOf(name),
null,
)?.use { c ->
if (c.moveToFirst()) {
val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
savedUri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id)
true
} else {
false
}
} ?: false
}
try {
assertArrayEquals(file.readBytes(), resolver.openInputStream(savedUri!!)?.use { it.readBytes() })
} finally {
savedUri?.let { resolver.delete(it, null, null) }
}
compose.onNodeWithTag("media_close_button").performClick()
compose.waitUntil(15_000) {
compose.onAllNodesWithTag("media_viewer_dialog").fetchSemanticsNodes().isEmpty()
}
}
@Test
fun invalidSourceShowsRetryAndCanClose() {
compose.setContent {
@@ -0,0 +1,162 @@
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.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.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.text.AnnotatedString
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.chat.ClarifyQuestionUi
import com.m57.hermescontrol.ui.chat.ClarifyUi
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ClarifyBubbleTest {
@get:Rule
val compose = createComposeRule()
@Test
fun clickingProvidedChoice_invokesOnRespondSingleWithExactChoice() {
var respondedSingle: String? = null
var respondedBatch: Map<String, String>? = null
var dismissed = false
val clarifyRequest =
ClarifyUi(
text = "Which environment should be targeted?",
options = listOf("Staging", "Production"),
clarifyId = "req-1",
)
compose.setContent {
HermesControlTheme {
ClarifyBubble(
clarifyRequest = clarifyRequest,
onRespondSingle = { respondedSingle = it },
onRespondBatch = { respondedBatch = it },
onDismiss = { dismissed = true },
)
}
}
compose.onNodeWithText("Staging").assertIsDisplayed().performClick()
compose.runOnIdle {
assertEquals("Staging", respondedSingle)
assertNull(respondedBatch)
assertEquals(false, dismissed)
}
}
@Test
fun clickingDismiss_invokesOnDismissWithoutResponse() {
var respondedSingle: String? = null
var respondedBatch: Map<String, String>? = null
var dismissed = false
val dismissLabel = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.chat_dismiss)
val clarifyRequest =
ClarifyUi(
text = "Do you want to run database migrations?",
options = listOf("Yes", "No"),
clarifyId = "req-2",
)
compose.setContent {
HermesControlTheme {
ClarifyBubble(
clarifyRequest = clarifyRequest,
onRespondSingle = { respondedSingle = it },
onRespondBatch = { respondedBatch = it },
onDismiss = { dismissed = true },
)
}
}
compose.onNodeWithText(dismissLabel).assertIsDisplayed().performClick()
compose.runOnIdle {
assertEquals(true, dismissed)
assertNull(respondedSingle)
assertNull(respondedBatch)
}
}
@Test
fun replacingRequestIdentity_resetsEnteredTextAndSelection() {
val sharedQuestion =
ClarifyQuestionUi(
qid = "q-common",
question = "Which build targets and extras should be enabled?",
choices = listOf("APK", "AAB", "Native Symbols"),
multiSelect = true,
)
var currentRequest by mutableStateOf(
ClarifyUi(
text = sharedQuestion.question,
clarifyId = "req-1",
questions = listOf(sharedQuestion),
),
)
compose.setContent {
HermesControlTheme {
ClarifyBubble(
clarifyRequest = currentRequest,
onRespondSingle = {},
onRespondBatch = {},
onDismiss = {},
)
}
}
compose
.onNodeWithText("APK")
.assertIsDisplayed()
.assertIsNotSelected()
.performClick()
compose.onNodeWithText("APK").assertIsSelected()
val textInputNode = compose.onNode(hasSetTextAction()).assertIsDisplayed()
textInputNode.performTextInput("--dry-run")
textInputNode.assert(hasEditableText("--dry-run"))
// Replace request identity while retaining identical qid, question, and options
compose.runOnIdle {
currentRequest =
ClarifyUi(
text = sharedQuestion.question,
clarifyId = "req-2",
questions = listOf(sharedQuestion),
)
}
// Selected choice and typed text must reset under new clarifyId
compose.onNodeWithText("APK").assertIsDisplayed().assertIsNotSelected()
compose.onNode(hasSetTextAction()).assertIsDisplayed().assert(hasEditableText(""))
}
// The field merges its placeholder label into Text, so assertTextEquals can
// never match; compare only the typed value.
private fun hasEditableText(value: String) =
SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString(value))
}
@@ -50,8 +50,8 @@ class ComposerReadinessTest {
compose.runOnIdle { assertEquals(0, sends) }
compose.runOnIdle { ready.value = true }
compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist()
// Ready + typing = an interruptible generation: Stop replaces the
// action glyph while queue-send stays available in the flat slot.
// Ready + typing = an interruptible generation: Send keeps the primary
// action slot and Stop moves to the flat slot beside it.
compose.onNodeWithTag("stop_button").assertExists()
compose.onNodeWithTag("send_button").assertIsEnabled().performClick()
compose.runOnIdle { assertEquals(1, sends) }
@@ -60,4 +60,28 @@ class ComposerReadinessTest {
compose.onNodeWithTag("send_button").assertIsNotEnabled()
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
}
@Test
fun emptyDraftWhileStreamingMakesStopThePrimaryAction() {
compose.setContent {
var input by remember { mutableStateOf(TextFieldValue("")) }
ChatInputBar(
inputFieldValue = input,
onInputChange = { input = it },
onSend = {},
onMicTap = {},
isListening = false,
isAgentTyping = true,
canInterrupt = true,
isConnected = true,
commandCatalog = CommandCatalog(),
isSessionReady = true,
)
}
compose.onNodeWithTag("stop_button").assertExists()
compose.onNodeWithTag("send_button").assertDoesNotExist()
compose.onNodeWithTag("chat_input").performTextInput("next")
compose.onNodeWithTag("send_button").assertExists()
compose.onNodeWithTag("stop_button").assertExists()
}
}
@@ -0,0 +1,165 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.activity.ComponentActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class CopyFeedbackTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Composable
private fun CopyFeedbackHarness(
tagPrefix: String,
stateReceiver: (MutableState<Boolean>) -> Unit = {},
) {
val feedbackState = rememberCopyFeedback()
stateReceiver(feedbackState)
Column {
Text(
text = if (feedbackState.value) "Copied" else "Idle",
modifier = Modifier.testTag("${tagPrefix}_status"),
)
Text(
text = "Trigger",
modifier =
Modifier
.testTag("${tagPrefix}_trigger")
.clickable { feedbackState.value = true },
)
}
}
@Test
fun initialStateIsFalse() {
composeTestRule.setContent {
CopyFeedbackHarness(tagPrefix = "feedback")
}
composeTestRule
.onNodeWithTag("feedback_status")
.assertIsDisplayed()
.assertTextEquals("Idle")
}
@Test
fun settingTrueShowsFeedbackAndResetsAfter1500Ms() {
composeTestRule.mainClock.autoAdvance = false
composeTestRule.setContent {
CopyFeedbackHarness(tagPrefix = "feedback")
}
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle")
composeTestRule.onNodeWithTag("feedback_trigger").performClick()
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
// Advance 1400ms - should still be true (100ms before 1500ms timeout)
composeTestRule.mainClock.advanceTimeBy(1400L)
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
// Advance another 100ms to hit exactly 1500ms
composeTestRule.mainClock.advanceTimeBy(100L)
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle")
}
@Test
fun settingTrueAgainWhileAlreadyTrueDoesNotRestartActiveTimer() {
composeTestRule.mainClock.autoAdvance = false
composeTestRule.setContent {
CopyFeedbackHarness(tagPrefix = "feedback")
}
// Trigger at t = 0ms
composeTestRule.onNodeWithTag("feedback_trigger").performClick()
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
// Advance to t = 1000ms
composeTestRule.mainClock.advanceTimeBy(1000L)
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
// Re-trigger setting true at t = 1000ms while already true
composeTestRule.onNodeWithTag("feedback_trigger").performClick()
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
// If the active timer is NOT restarted, it resets at t = 1500ms (500ms after the second click)
composeTestRule.mainClock.advanceTimeBy(500L)
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle")
}
@Test
fun separateComposedHookInstancesAreIndependent() {
composeTestRule.mainClock.autoAdvance = false
composeTestRule.setContent {
Column {
CopyFeedbackHarness(tagPrefix = "instance_a")
CopyFeedbackHarness(tagPrefix = "instance_b")
}
}
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle")
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle")
// Trigger only instance A
composeTestRule.onNodeWithTag("instance_a_trigger").performClick()
composeTestRule.mainClock.advanceTimeByFrame()
// Instance A should be active, instance B should remain idle
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Copied")
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle")
// Advance 1000ms
composeTestRule.mainClock.advanceTimeBy(1000L)
// Trigger instance B at t = 1000ms
composeTestRule.onNodeWithTag("instance_b_trigger").performClick()
composeTestRule.mainClock.advanceTimeByFrame()
// Both should be Copied now
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Copied")
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Copied")
// Advance 500ms to t = 1500ms: Instance A should expire, Instance B should still have 1000ms remaining
composeTestRule.mainClock.advanceTimeBy(500L)
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle")
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Copied")
// Advance remaining 1000ms for Instance B
composeTestRule.mainClock.advanceTimeBy(1000L)
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle")
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle")
}
}
@@ -219,6 +219,10 @@ class VoiceNoteGestureTest {
composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) }
composeTestRule.mainClock.advanceTimeBy(600)
// The strip takes the field's decoration box mid-hold; the field must
// keep its focus so the keyboard never collapses when recording starts
// (device follow-up, #1247).
composeTestRule.onNodeWithTag("chat_input").assertIsFocused()
composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() }
composeTestRule.waitForIdle()
@@ -12,7 +12,8 @@ import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ChatTimelineState
import com.m57.hermescontrol.ui.chat.ChatUiState
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.StreamingState
@@ -21,7 +22,6 @@ import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
@@ -116,31 +116,58 @@ class FullBleedChatAnchorTest {
val tailKeys =
listOf("agent_status", "clarify_bubble", "vault_unlock_card", "vault_save_login_card", "vault_code_card")
val listState = LazyListState(index, offset)
val viewModel = mockk<ChatViewModel>(relaxed = true)
lateinit var scope: CoroutineScope
compose.setContent {
scope = rememberCoroutineScope()
val controller = rememberChatScrollController(listState, scope)
Box(Modifier.size(width = 320.dp, height = if (tailKey == null) 400.dp else 24.dp)) {
FullBleedChatList(
messages = messages.value,
streamingState = streaming.value,
isAgentTyping = tailKey != null,
transcript =
TranscriptUiState.resolve(
chat =
ChatUiState(
messages = messages.value,
isAgentTyping = tailKey != null,
typingEffectEnabled = false,
typingEffectDelayMs = 30,
isLoadingOlder = loading.value,
currentSessionId = session.value,
clarifyRequest =
if (tailKey !=
null
) {
ClarifyUi("Choose an option", listOf("Yes"))
} else {
null
},
vaultUnlockPrompt =
if (tailKey != null) {
VaultUnlockPromptUi(
null,
null,
)
} else {
null
},
vaultSaveLoginPrompt =
if (tailKey !=
null
) {
VaultSaveLoginPromptUi(null, null)
} else {
null
},
vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null,
),
timeline = ChatTimelineState(),
streaming = streaming.value,
savingAttachmentPath = null,
speakingMessageId = null,
),
actions = testTranscriptActions(),
searchState = ChatSearchState(),
typingEffectEnabled = false,
typingEffectDelayMs = 30,
isLoading = false,
isLoadingOlder = loading.value,
listState = listState,
scrollController = controller,
lastAnimatedMessageId = null,
onLastAnimatedMessageIdChange = {},
viewModel = viewModel,
pagingSessionId = session.value,
clarifyRequest = if (tailKey != null) ClarifyUi("Choose an option", listOf("Yes")) else null,
vaultUnlockPrompt = if (tailKey != null) VaultUnlockPromptUi(null, null) else null,
vaultSaveLoginPrompt = if (tailKey != null) VaultSaveLoginPromptUi(null, null) else null,
vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null,
)
}
}
@@ -3,6 +3,9 @@ package com.m57.hermescontrol.ui.chat.fullbleed
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertCountEquals
@@ -12,19 +15,23 @@ import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ChatTimelineState
import com.m57.hermescontrol.ui.chat.ChatUiState
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.SearchTarget
import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.ToolStatus
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import org.junit.Assert.assertFalse
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -72,44 +79,87 @@ class FullBleedChatListTest {
showUserMessageTokens: Boolean = true,
showAssistantMessageTokens: Boolean = true,
showTokensPerSecond: Boolean = true,
searchState: ChatSearchState = ChatSearchState(),
onController: (ChatScrollController) -> Unit = {},
) {
composeTestRule.setContent {
val listState = LazyListState()
val listState = remember { LazyListState() }
// Frame-clocked scope, matching ChatScreen: animated scrolls need a MonotonicFrameClock.
val scrollController = rememberChatScrollController(listState, rememberCoroutineScope())
SideEffect { onController(scrollController) }
FullBleedChatList(
messages = messages,
streamingState = streamingState,
isAgentTyping = isAgentTyping,
searchState = ChatSearchState(),
typingEffectEnabled = false,
typingEffectDelayMs = 30,
messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens,
showAssistantMessageTokens = showAssistantMessageTokens,
showTokensPerSecond = showTokensPerSecond,
isLoading = false,
isLoadingOlder = false,
listState = listState,
scrollController =
ChatScrollController(
listState = listState,
scope = CoroutineScope(Dispatchers.Main.immediate),
transcript =
TranscriptUiState.resolve(
chat =
ChatUiState(
messages = messages,
isAgentTyping = isAgentTyping,
typingEffectEnabled = false,
typingEffectDelayMs = 30,
messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens,
showAssistantMessageTokens = showAssistantMessageTokens,
showTokensPerSecond = showTokensPerSecond,
clarifyRequest =
if (clarify) {
com.m57.hermescontrol.ui.chat.ClarifyUi(
text = "pick",
options = listOf("a"),
)
} else {
null
},
),
timeline = ChatTimelineState(),
streaming = streamingState,
savingAttachmentPath = null,
speakingMessageId = null,
),
lastAnimatedMessageId = null,
onLastAnimatedMessageIdChange = {},
viewModel = mockk<ChatViewModel>(relaxed = true),
clarifyRequest =
if (clarify) {
com.m57.hermescontrol.ui.chat.ClarifyUi(
text = "pick",
options = listOf("a"),
)
} else {
null
},
actions = testTranscriptActions(),
searchState = searchState,
listState = listState,
scrollController = scrollController,
)
}
}
@Test
fun upwardUserGesture_pausesBottomFollow() {
lateinit var scrollController: ChatScrollController
render(
messages = List(40) { index -> msg("u$index", MessageRole.USER) },
onController = { scrollController = it },
)
composeTestRule.runOnIdle { scrollController.jumpToBottom() }
composeTestRule.waitForIdle()
composeTestRule.onRoot().performTouchInput { swipeDown() }
composeTestRule.runOnIdle { assertFalse(scrollController.isFollowingBottom) }
}
@Test
fun searchNavigatesToToolRowBeyondInitialViewport() {
val search =
ChatSearchState().apply {
isActive = true
query = "terminal"
matchIndices = listOf(30)
matchOffsets = listOf(0)
matchTargets = listOf(SearchTarget.TOOL)
currentIndex = 0
matchedIds = setOf("tool")
currentMatchId = "tool"
}
render(
messages =
List(30) { index -> msg("user-$index", MessageRole.USER) } +
ChatMessage("tool", MessageRole.TOOL, "opaque", toolName = "terminal"),
searchState = search,
)
composeTestRule.onNodeWithTag("fullbleed_tool_row").assertIsDisplayed()
composeTestRule.onNodeWithText("terminal").assertIsDisplayed()
}
@Test
fun userMessages_keepBubble_agentProse_isFullBleed() {
render(
@@ -0,0 +1,21 @@
package com.m57.hermescontrol.ui.chat.fullbleed
/** No-op actions for transcript layout tests; callbacks are irrelevant to their assertions. */
internal fun testTranscriptActions() =
TranscriptActions(
onLoadOlder = {},
onOpenAttachment = {},
onSaveAttachment = {},
onImageClick = {},
onRespondApproval = {},
onRespondClarify = {},
onRespondClarifyBatch = {},
onDismissClarify = {},
onRespondVaultUnlock = {},
onDismissVaultUnlock = {},
onRespondVaultSaveLogin = { _, _ -> },
onDismissVaultSaveLogin = {},
onRespondVaultCode = {},
onDismissVaultCode = {},
onToggleSpeak = {},
)
@@ -0,0 +1,81 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.SessionLiveStatus
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.theme.resolveStatusColors
import com.m57.hermescontrol.ui.sessions.components.SessionLiveStatusIndicator
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class StatusBadgeColorTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun latteBadgesRenderStatusFillsAndMatchingOnColors() {
val types = StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL }
composeTestRule.setContent {
HermesControlTheme(themePreference = ThemePreference.LIGHT, themePreset = ThemePreset.CATPPUCCIN) {
Column {
types.forEach { type ->
StatusBadge(type.name, type, Modifier.testTag("badge_$type"))
}
}
}
}
val colors = resolveStatusColors(ThemePreset.CATPPUCCIN, darkTheme = false)
types.forEach { type ->
val (background, foreground) = requireNotNull(statusBadgeColors(type, colors))
assertRenderedPair("badge_$type", background.toArgb(), foreground.toArgb())
}
}
@Test
fun nordLiveIndicatorsRenderStatusFillsAndMatchingOnColors() {
composeTestRule.setContent {
HermesControlTheme(themePreference = ThemePreference.LIGHT, themePreset = ThemePreset.NORD) {
Column {
SessionLiveStatusIndicator(SessionLiveStatus.WORKING, "working")
SessionLiveStatusIndicator(SessionLiveStatus.WAITING, "waiting")
}
}
}
val colors = resolveStatusColors(ThemePreset.NORD, darkTheme = false)
assertRenderedPair("session_live_status_working", colors.success.toArgb(), colors.onSuccess.toArgb())
assertRenderedPair("session_live_status_waiting", colors.warning.toArgb(), colors.onWarning.toArgb())
}
private fun assertRenderedPair(
tag: String,
background: Int,
foreground: Int,
) {
// PR #1417: verify pixels as well as the palette mapping, so a renderer
// switching back to accent-on-container cannot leave the contrast test green.
val pixels = composeTestRule.onNodeWithTag(tag).captureToImage().toPixelMap()
assertEquals("$tag must render the status fill", background, pixels[pixels.width / 2, 1].toArgb())
assertTrue(
"$tag must render the matching on-status text/icon",
(0 until pixels.height).any { y ->
(0 until pixels.width).any { x -> pixels[x, y].toArgb() == foreground }
},
)
}
}
@@ -0,0 +1,238 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.layout.Column
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.unit.dp
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.theme.ThemeRegistry
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
@MediumTest
class ThemeComponentGalleryTest(
private val preset: ThemePreset,
private val dark: Boolean,
) {
@get:Rule
val composeTestRule = createComposeRule()
private var samples = emptyList<Sample>()
@Test
fun enabledControlsRenderTheirAccessibleDefaultStateColors() {
composeTestRule.setContent {
HermesControlTheme(
themePreset = preset,
themePreference = if (dark) ThemePreference.DARK else ThemePreference.LIGHT,
) {
Gallery()
}
}
composeTestRule.waitForIdle()
// Assert the public defaults from the installed Compose version, not a copied token table.
assertTrue("gallery must publish its state contracts", samples.isNotEmpty())
samples.forEach { sample ->
val a = sample.foreground.luminance()
val b = sample.background.luminance()
val ratio = (maxOf(a, b) + 0.05f) / (minOf(a, b) + 0.05f)
assertTrue("$preset dark=$dark ${sample.tag}: $ratio < ${sample.minimum}", ratio >= sample.minimum)
}
// Pixel evidence catches defaults that differ from the actual rendered state (including focus).
samples.groupBy { it.tag }.forEach { (tag, contracts) ->
val pixels =
composeTestRule
.onNodeWithTag(tag)
.performScrollTo()
.captureToImage()
.toPixelMap()
val rendered =
buildSet {
for (y in 0 until pixels.height) {
for (x in 0 until pixels.width) add(pixels[x, y].toArgb())
}
}
contracts.forEach { sample ->
assertTrue("$preset dark=$dark $tag must render its foreground", sample.foreground.toArgb() in rendered)
if (sample.checkBackground) {
assertTrue("$preset dark=$dark $tag must render its fill", sample.background.toArgb() in rendered)
}
}
}
}
@Composable
private fun Gallery() {
val c = MaterialTheme.colorScheme
val switches = SwitchDefaults.colors()
val radios = RadioButtonDefaults.colors()
val checks = CheckboxDefaults.colors()
val sliders = SliderDefaults.colors()
val fields = OutlinedTextFieldDefaults.colors()
val segments = SegmentedButtonDefaults.colors()
val focus = remember { FocusRequester() }
SideEffect {
samples =
listOf(
Sample("switch_on", switches.checkedThumbColor, switches.checkedTrackColor, checkBackground = true),
Sample("switch_on", switches.checkedTrackColor, c.surface),
Sample(
"switch_off",
switches.uncheckedThumbColor,
switches.uncheckedTrackColor,
checkBackground = true,
),
Sample("switch_off", switches.uncheckedBorderColor, c.surface),
Sample("radio_on", radios.selectedColor, c.surface),
Sample("radio_off", radios.unselectedColor, c.surface),
Sample("check_on", checks.checkedCheckmarkColor, checks.checkedBoxColor, checkBackground = true),
Sample("check_on", checks.checkedBorderColor, c.surface),
Sample("check_off", checks.uncheckedBorderColor, c.surface),
Sample("slider", sliders.activeTrackColor, sliders.inactiveTrackColor, checkBackground = true),
Sample(
"field_normal",
fields.unfocusedIndicatorColor,
fields.unfocusedContainerColor.compositeOver(c.surface),
),
Sample("field_normal", fields.unfocusedLabelColor, c.surface, 4.5f),
Sample(
"field_focus",
fields.focusedIndicatorColor,
fields.focusedContainerColor.compositeOver(c.surface),
),
Sample("field_focus", fields.focusedLabelColor, c.surface, 4.5f),
Sample(
"field_error",
fields.errorIndicatorColor,
fields.errorContainerColor.compositeOver(c.surface),
),
Sample("field_error", fields.errorSupportingTextColor, c.surface, 4.5f),
Sample("segment_on", segments.activeContentColor, segments.activeContainerColor, 4.5f, true),
Sample("segment_on", segments.activeBorderColor, segments.activeContainerColor),
Sample(
"segment_off",
segments.inactiveContentColor,
segments.inactiveContainerColor.compositeOver(c.surface),
4.5f,
),
Sample("segment_off", segments.inactiveBorderColor, c.surface),
Sample("primary_text", c.primary, c.surface, 4.5f),
Sample("error_text", c.error, c.surface, 4.5f),
)
}
Surface(color = c.surface, modifier = Modifier.fillMaxSize()) {
Column(Modifier.verticalScroll(rememberScrollState()).padding(8.dp)) {
Text("$preset ${if (dark) "dark" else "light"}")
Row {
Switch(true, {}, Modifier.testTag("switch_on"))
Switch(false, {}, Modifier.testTag("switch_off"))
RadioButton(true, {}, Modifier.testTag("radio_on"))
RadioButton(false, {}, Modifier.testTag("radio_off"))
}
Row {
Checkbox(true, {}, Modifier.testTag("check_on"))
Checkbox(false, {}, Modifier.testTag("check_off"))
}
Slider(0.5f, {}, Modifier.testTag("slider"))
OutlinedTextField("Value", {
}, Modifier.fillMaxWidth().testTag("field_normal"), readOnly = true, label = { Text("Normal") })
OutlinedTextField(
"Value",
{
},
Modifier
.fillMaxWidth()
.focusRequester(
focus,
).testTag("field_focus"),
readOnly = true,
label = { Text("Focused") },
)
OutlinedTextField("Value", {
}, Modifier.fillMaxWidth().testTag("field_error"), readOnly = true, isError = true, label = {
Text("Error")
}, supportingText = { Text("Error text") })
SingleChoiceSegmentedButtonRow {
SegmentedButton(
true,
{},
SegmentedButtonDefaults.itemShape(0, 2),
Modifier.testTag("segment_on"),
) { Text("Selected") }
SegmentedButton(
false,
{
},
SegmentedButtonDefaults.itemShape(
1,
2,
),
Modifier.testTag("segment_off"),
) { Text("Unselected") }
}
Text("Primary foreground", Modifier.testTag("primary_text"), color = c.primary)
Text("Error foreground", Modifier.testTag("error_text"), color = c.error)
}
}
LaunchedEffect(Unit) { focus.requestFocus() }
}
private data class Sample(
val tag: String,
val foreground: Color,
val background: Color,
val minimum: Float = 3f,
val checkBackground: Boolean = false,
)
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0} dark={1}")
fun presets(): List<Array<Any>> =
ThemeRegistry.flatMap { (preset, _) -> listOf(true, false).map { arrayOf<Any>(preset, it) } }
}
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hermes Dev</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hermes Dev</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hermes Dev</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hermes Dev</string>
</resources>
+4
View File
@@ -71,6 +71,10 @@
android:name=".notification.NotificationReplyReceiver"
android:exported="false" />
<receiver
android:name=".notification.StopKeepConnectedReceiver"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -215,14 +215,22 @@ fun MainNavigation(sessionId: String? = null) {
var lastBackPressTime by remember { mutableLongStateOf(0L) }
val backToastText = stringResource(R.string.press_back_again_to_exit)
// Double-back to exit on root ChatScreen
// Double-back to exit on root ChatScreen — unless the chat was opened from
// a list (History, Bots): then the first back returns to that list (Telegram
// chat-list parity) and only a further double-back exits.
BackHandler(enabled = currentScreen == ChatScreen && backStack.size == 1) {
val now = System.currentTimeMillis()
if (now - lastBackPressTime < 2000L) {
(context as? Activity)?.finish()
val pendingReturn = NavigationController.chatReturnScreen
if (pendingReturn != null) {
NavigationController.consumeChatReturnScreen()
NavigationController.navigateTo(pendingReturn)
} else {
lastBackPressTime = now
Toast.makeText(context, backToastText, Toast.LENGTH_SHORT).show()
val now = System.currentTimeMillis()
if (now - lastBackPressTime < 2000L) {
(context as? Activity)?.finish()
} else {
lastBackPressTime = now
Toast.makeText(context, backToastText, Toast.LENGTH_SHORT).show()
}
}
}
@@ -33,6 +33,16 @@ object NavigationController {
private set
var pendingNewChatNavigation: PendingNewChatNavigation? by mutableStateOf(null)
private set
/**
* The list screen a chat was opened from (History, Bots). Back out of that
* chat returns here instead of exiting the app (Telegram chat-list parity).
* Consumed by MainNavigation's root-chat back handler; cleared by any other
* navigation so a stale target can never hijack a later back press.
*/
var chatReturnScreen: NavKey? by mutableStateOf(null)
private set
val pendingSessionId: String? get() = pendingChatNavigation?.sessionId
private var nextChatNavigationRequestId = 0L
@@ -45,8 +55,18 @@ object NavigationController {
*/
fun isPrimaryScreen(key: NavKey): Boolean = key == ChatScreen || ScreenRegistry.ALL_SCREENS.any { it.key == key }
/** Returns the armed return screen and disarms it. */
fun consumeChatReturnScreen(): NavKey? = chatReturnScreen.also { chatReturnScreen = null }
fun navigateTo(key: NavKey) {
val stack = backStack ?: return
// Any explicit navigation invalidates the pending chat return — only a
// session/chat opened from a list arms it again (see [queueChatNavigation]).
// This must precede the dedup guard: re-selecting Chat in the drawer is
// still an explicit navigation and must not keep a stale return armed.
chatReturnScreen = null
if (stack.lastOrNull() == key) return
if (key == ChatScreen) {
@@ -73,11 +93,16 @@ object NavigationController {
}
fun openChatSession(sessionId: String) {
queueChatNavigation(sessionId, scrollToBottom = false)
// Opened from a list (History cards, search results, Bots): arm the
// return so back from the chat lands back on that list instead of
// exiting the app (Telegram chat-list parity).
queueChatNavigation(sessionId, scrollToBottom = false, returnToList = true)
}
fun openChatSessionFromNotification(sessionId: String) {
queueChatNavigation(sessionId, scrollToBottom = true)
// A notification open is not a list navigation: back keeps the classic
// root-chat exit flow.
queueChatNavigation(sessionId, scrollToBottom = true, returnToList = false)
}
fun openNewChat() {
@@ -91,8 +116,18 @@ object NavigationController {
private fun queueChatNavigation(
sessionId: String,
scrollToBottom: Boolean,
returnToList: Boolean,
) {
if (sessionId.isBlank()) return
// Snapshot the origin BEFORE navigateTo resets the stack to [ChatScreen].
val returnScreen =
if (returnToList) {
backStack
?.lastOrNull()
?.takeIf { it != ChatScreen && isPrimaryScreen(it) }
} else {
null
}
pendingChatNavigation =
PendingChatNavigation(
sessionId = sessionId,
@@ -100,6 +135,8 @@ object NavigationController {
requestId = ++nextChatNavigationRequestId,
)
navigateTo(ChatScreen)
// Set after navigateTo so the reset itself does not disarm the return.
chatReturnScreen = returnScreen
}
fun consumePendingChatNavigation(): PendingChatNavigation? =
@@ -113,6 +150,7 @@ object NavigationController {
/** Clear the stack and navigate to the given screen atomically. */
fun resetTo(screen: NavKey) {
val stack = backStack ?: return
chatReturnScreen = null
stack.clear()
stack.add(screen)
}
@@ -32,6 +32,9 @@ data class ServerStoreState(
val showTokensPerSecond: Boolean = true,
val showModelProvider: Boolean = false,
val keepConnectedInBackground: Boolean = false,
// Notify when ANY session finishes while the app is backgrounded. Needs a
// live background connection, so it implies the keep-connected lease.
val notifySessionCompletions: Boolean = false,
// Opt-in update channel: when true the in-app update check also considers
// pre-release release-candidate GitHub releases, not just stable ones.
val checkReleaseCandidateUpdates: Boolean = false,
@@ -871,6 +871,12 @@ object AuthManager {
serverStore.update { it.copy(keepConnectedInBackground = enabled) }
}
fun isNotifySessionCompletions(): Boolean = serverStore.getLatestState().notifySessionCompletions
fun setNotifySessionCompletions(enabled: Boolean) {
serverStore.update { it.copy(notifySessionCompletions = enabled) }
}
// ── Chat Font Scale (issue #1004) ───────────────────────────────────
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
@@ -66,7 +66,9 @@ internal fun canonicalMessageOrder(
id: String,
sessionId: String,
): Long? {
val prefix = "rest-$sessionId-"
val prefix =
com.m57.hermescontrol.ui.chat.RestMessageId
.sessionPrefix(sessionId)
if (!id.startsWith(prefix)) return null
val suffix = id.removePrefix(prefix)
return suffix.takeIf { it.isNotEmpty() && it.all { char -> char in '0'..'9' } }?.toLongOrNull()
@@ -17,6 +17,9 @@ import kotlinx.serialization.Serializable
* file that lives on the *gateway host* (agent-delivered `MEDIA:` directive).
* Null for locally-picked attachments.
* @param source Where the attachment originated.
* @param contentOffset For agent `MEDIA:` attachments, the char offset in the message's
* stripped text where the directive appeared, so the renderer can place it in-flow.
* Null means "append after the text".
*/
@Serializable
data class Attachment(
@@ -26,6 +29,7 @@ data class Attachment(
val size: Long = 0,
val gatewayUrl: String? = null,
val source: AttachmentSource = AttachmentSource.LOCAL,
val contentOffset: Int? = null,
) {
val mediaKind: MediaKind
get() = classifyMedia(mimeType = mimeType, name = name, uri = gatewayUrl ?: uri)
@@ -0,0 +1,11 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
/** Body for POST api/credentials/pool. `apiKey` serializes as `api_key`. */
@Serializable
data class CredentialPoolAddRequest(
val provider: String,
val apiKey: String,
val label: String? = null,
)
@@ -9,3 +9,8 @@ data class DebugShareResponse(
val redacted: Boolean? = null,
val auto_delete_seconds: Long? = null,
)
@Serializable
data class DebugShareRequest(
val redact: Boolean? = null,
)
@@ -17,3 +17,19 @@ data class HookEntry(
val approved_at: String? = null,
val executable: Boolean? = null,
)
/** Body for POST api/ops/hooks. The backend field is `approve` (writes the consent allowlist entry). */
@Serializable
data class HookCreateRequest(
val event: String,
val command: String,
val matcher: String? = null,
val timeout: Int? = null,
val approve: Boolean? = null,
)
@Serializable
data class HookDeleteRequest(
val event: String,
val command: String,
)
@@ -1,6 +1,9 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@Serializable
data class McpServersResponse(
@@ -19,12 +22,21 @@ data class McpServer(
val status: String? = null,
val error: String? = null,
val auth: String? = null,
val tools: List<String>? = null,
val tools: JsonElement? = 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"
// Backend sends `tools` as null, a name list, or a `{include, exclude}` filter object.
val toolCount: Int?
get() =
when (val t = tools) {
is JsonArray -> t.size
is JsonObject -> (t["include"] as? JsonArray)?.size
else -> null
}
}
@Serializable
@@ -71,13 +83,15 @@ data class McpCatalogEntry(
val url: String? = null,
val command: String? = null,
val args: List<String>? = null,
val env: List<McpCatalogEnvVar>? = null,
@SerialName("required_env") val env: List<McpCatalogEnvVar>? = null,
val installed: Boolean = false,
val enabled: Boolean = false,
)
@Serializable
data class McpCatalogEnvVar(
val key: String,
val label: String? = null,
@SerialName("name") val key: String,
@SerialName("prompt") val label: String? = null,
val description: String? = null,
val required: Boolean = false,
)
@@ -96,3 +110,9 @@ data class McpOAuthFlowResponse(
val authorizationUrl: String? = null,
val error: String? = null,
)
/** Body for PUT api/mcp/servers/{name}; only the fields being changed are sent. */
@Serializable
data class McpServerUpdateRequest(
val env: Map<String, String>? = null,
)
@@ -0,0 +1,33 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/** One emoji reaction on a message. Tapback semantics: at most one per [author] per message. */
data class MessageReaction(
val emoji: String,
val author: String,
)
/** Reactions carried by a live `message.reaction` event payload (`reactions` list of maps). */
internal fun parseMessageReactions(raw: Any?): List<MessageReaction> =
(raw as? List<*>).orEmpty().mapNotNull { item ->
val map = item as? Map<*, *> ?: return@mapNotNull null
val emoji = (map["emoji"] as? String)?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MessageReaction(emoji = emoji, author = map["author"] as? String ?: "")
}
/** Reactions persisted under `display_metadata.reactions` on a REST transcript row. */
internal fun parseMessageReactions(displayMetadata: JsonElement?): List<MessageReaction> {
val list = (displayMetadata as? JsonObject)?.get("reactions") as? JsonArray ?: return emptyList()
return list.mapNotNull { item ->
val obj = item as? JsonObject ?: return@mapNotNull null
val emoji =
(obj["emoji"] as? JsonPrimitive)?.takeIf { it.isString }?.content?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
val author = (obj["author"] as? JsonPrimitive)?.takeIf { it.isString }?.content.orEmpty()
MessageReaction(emoji = emoji, author = author)
}
}
@@ -25,14 +25,20 @@ data class MainModelAssignment(
data class MoaModelSlot(
val provider: String,
val model: String,
val reasoning_effort: String? = null,
val enabled: Boolean = true,
)
@Serializable
data class MoaConfigPreset(
val reference_models: List<MoaModelSlot>,
val aggregator: MoaModelSlot,
val reference_temperature: Double = 0.7,
val aggregator_temperature: Double = 0.3,
// null = omitted from API calls (provider default); the backend sends null when unset.
val reference_temperature: Double? = null,
val aggregator_temperature: Double? = null,
val reference_timeout: Double? = null,
val degraded_reference_policy: String = "loud",
val fanout: String? = null,
val max_tokens: Int = 4096,
val enabled: Boolean = true,
)
@@ -44,8 +50,12 @@ data class MoaConfigResponse(
val presets: Map<String, MoaConfigPreset> = emptyMap(),
val reference_models: List<MoaModelSlot> = emptyList(),
val aggregator: MoaModelSlot = MoaModelSlot("", ""),
val reference_temperature: Double = 0.7,
val aggregator_temperature: Double = 0.3,
// null = omitted from API calls (provider default); the backend sends null when unset.
val reference_temperature: Double? = null,
val aggregator_temperature: Double? = null,
val reference_timeout: Double? = null,
val degraded_reference_policy: String = "loud",
val fanout: String? = null,
val max_tokens: Int = 4096,
val enabled: Boolean = true,
)
@@ -1,7 +1,17 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
/**
* OAuth provider catalog entry returned by `GET /api/providers/oauth`.
@@ -10,7 +20,8 @@ import kotlinx.serialization.Serializable
* id, name, flow ("pkce" | "device_code" | "external"), cli_command,
* docs_url, disconnect_hint, disconnect_command, disconnectable, status.
*
* `expires_at` in the status comes back as an ISO timestamp string.
* `expires_at` / `last_refresh` come back as an ISO string or a numeric epoch depending on the
* credential source, so they decode through [LenientStringSerializer].
*/
@Serializable
data class OAuthProvider(
@@ -31,9 +42,13 @@ data class OAuthProviderStatus(
val source: String? = null,
@SerialName("source_label") val sourceLabel: String? = null,
@SerialName("token_preview") val tokenPreview: String? = null,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("expires_at")
@Serializable(with = LenientStringSerializer::class)
val expiresAt: String? = null,
@SerialName("has_refresh_token") val hasRefreshToken: Boolean? = null,
@SerialName("last_refresh") val lastRefresh: String? = null,
@SerialName("last_refresh")
@Serializable(with = LenientStringSerializer::class)
val lastRefresh: String? = null,
val error: String? = null,
)
@@ -98,3 +113,25 @@ data class OAuthCancelResponse(
@SerialName("session_id") val sessionId: String? = null,
val message: String? = null,
)
/** Decodes a JSON string or number (e.g. epoch millis) into its string form; null stays null. */
@OptIn(ExperimentalSerializationApi::class)
object LenientStringSerializer : KSerializer<String?> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("LenientString", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): String? {
if (decoder is JsonDecoder) {
val element = decoder.decodeJsonElement()
return if (element is JsonPrimitive && element !is JsonNull) element.content else null
}
return decoder.decodeString()
}
override fun serialize(
encoder: Encoder,
value: String?,
) {
if (value == null) encoder.encodeNull() else encoder.encodeString(value)
}
}
@@ -123,7 +123,10 @@ data class SessionRenameRequest(
@Serializable
data class BulkDeleteRequest(
val ids: List<String>,
val delete_all: Boolean = false,
// Destructive POSTs read the profile off the BODY (not the ?profile=
// query rewrite) — an unnamed bulk delete is refused with 400 on a
// multi-profile backend. Null stays valid for single-profile hosts.
val profile: String? = null,
)
@Serializable
@@ -134,5 +137,13 @@ data class BulkDeleteResponse(
@Serializable
data class PruneRequest(
val days: Int,
// Server contract: `older_than_days` (hermes_cli/web_models.py SessionPrune).
// The old `days` key was unknown to the backend, so the user's choice was
// silently dropped and the server pruned its own 90-day default.
@SerialName("older_than_days") val olderThanDays: Int,
// Destructive POSTs read the profile off the BODY (not the ?profile= query
// rewrite): on a multi-profile backend an unnamed prune is refused with
// 400 (web_routers/_common.py destructive_profile). Null stays valid for
// single-profile hosts.
val profile: String? = null,
)
@@ -30,6 +30,7 @@ data class ActiveSessionItem(
val status: String? = null,
@SerialName("last_active")
val lastActive: Double? = null,
val title: String? = null,
)
/**
@@ -75,6 +75,10 @@ data class SessionMessage(
val timestampText: String?
get() = (timestamp as? JsonPrimitive)?.content
/** Emoji reactions persisted on this row (`display_metadata.reactions`). */
val reactions: List<MessageReaction>
get() = parseMessageReactions(display_metadata)
val tokenCount: Int?
get() {
val prim = token_count as? JsonPrimitive ?: return null
@@ -20,10 +20,12 @@ import com.m57.hermescontrol.data.model.CreateCronJobRequest
import com.m57.hermescontrol.data.model.CreateProfileRequest
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateWebhookRequest
import com.m57.hermescontrol.data.model.CredentialPoolAddRequest
import com.m57.hermescontrol.data.model.CredentialPoolResponse
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.CuratorResponse
import com.m57.hermescontrol.data.model.DebugShareRequest
import com.m57.hermescontrol.data.model.DebugShareResponse
import com.m57.hermescontrol.data.model.DeleteWebhookResponse
import com.m57.hermescontrol.data.model.DeliveryTargetsResponse
@@ -38,6 +40,8 @@ import com.m57.hermescontrol.data.model.EnvVarUpdate
import com.m57.hermescontrol.data.model.GatewayMigrationPlan
import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse
import com.m57.hermescontrol.data.model.HealthStatus
import com.m57.hermescontrol.data.model.HookCreateRequest
import com.m57.hermescontrol.data.model.HookDeleteRequest
import com.m57.hermescontrol.data.model.HookResponse
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
import com.m57.hermescontrol.data.model.KanbanBoardResponse
@@ -57,6 +61,7 @@ import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServerToggleRequest
import com.m57.hermescontrol.data.model.McpServerUpdateRequest
import com.m57.hermescontrol.data.model.McpServersResponse
import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse
import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateRequest
@@ -583,7 +588,7 @@ interface HermesApiService : KanbanApiService {
@PUT("api/mcp/servers/{name}")
suspend fun updateMcpServer(
@Path("name") name: String,
@Body body: Map<String, Any>,
@Body body: McpServerUpdateRequest,
): Response<McpServer>
@POST("api/mcp/servers/{name}/auth")
@@ -602,7 +607,7 @@ interface HermesApiService : KanbanApiService {
@POST("api/mcp/catalog/install")
suspend fun installMcpCatalogEntry(
@Body body: McpCatalogInstallRequest,
): Response<Map<String, Any>>
): Response<ActionResponse>
@GET("api/webhooks")
suspend fun getWebhooks(): Response<WebhooksResponse>
@@ -881,7 +886,7 @@ interface HermesApiService : KanbanApiService {
@PUT("api/curator/paused")
suspend fun setCuratorPaused(
@Body body: Map<String, Boolean>,
): Response<Map<String, Any>>
): Response<ActionResponse>
@POST("api/curator/run")
suspend fun runCurator(): Response<ActionResponse>
@@ -918,14 +923,14 @@ interface HermesApiService : KanbanApiService {
@POST("api/credentials/pool")
suspend fun addCredentialPoolEntry(
@Body body: Map<String, String>,
): Response<Map<String, Any>>
@Body body: CredentialPoolAddRequest,
): Response<ActionResponse>
@DELETE("api/credentials/pool/{provider}/{index}")
suspend fun removeCredentialPoolEntry(
@Path("provider") provider: String,
@Path("index") index: Int,
): Response<Map<String, Any>>
): Response<ActionResponse>
// ── Admin: Operations ─────────────────────────────────────────────
@POST("api/ops/security-audit")
@@ -968,7 +973,7 @@ interface HermesApiService : KanbanApiService {
// ── Admin: Debug share ────────────────────────────────────────────
@POST("api/ops/debug-share")
suspend fun runDebugShare(
@Body body: Map<String, Any>,
@Body body: DebugShareRequest,
): Response<DebugShareResponse>
// ── Admin: Checkpoints ────────────────────────────────────────────
@@ -984,13 +989,13 @@ interface HermesApiService : KanbanApiService {
@POST("api/ops/hooks")
suspend fun createHook(
@Body body: Map<String, Any>,
): Response<Map<String, Any>>
@Body body: HookCreateRequest,
): Response<ActionResponse>
@DELETE("api/ops/hooks")
@HTTP(method = "DELETE", path = "api/ops/hooks", hasBody = true)
suspend fun deleteHook(
@Body body: Map<String, String>,
): Response<Map<String, Any>>
@Body body: HookDeleteRequest,
): Response<ActionResponse>
// ── Admin: Action status (log viewer) ────────────────────────────
@GET("api/actions/{name}/status")
@@ -33,6 +33,10 @@ object ProfileScopeInterceptor : Interceptor {
// multi-profile host transcribes under the launch profile
// (review, PR #1250).
"/api/audio",
// Curator state is per profile (config-scoped reads) and the run
// trigger is privileged: `POST /api/curator/run` is refused with
// 400 on a multi-profile backend without ?profile=.
"/api/curator",
"/api/config",
"/api/credentials/pool",
"/api/cron",
@@ -222,7 +222,10 @@ open class AppUpdateChecker(
val url = NEXT_LINK.find(link)?.groupValues?.get(1) ?: return null
val next = url.toHttpUrlOrNull() ?: throw IOException("GitHub returned an invalid pagination URL")
val base = apiBaseUrl.toHttpUrlOrNull() ?: throw IOException("Invalid GitHub API base URL")
if (next.host != base.host || next.encodedPath != base.encodedPath.trimEnd('/') + "/releases") {
val expectedReposPath = base.encodedPath.trimEnd('/') + "/releases"
val isReposPath = next.encodedPath == expectedReposPath
val isRepositoriesPath = REPOSITORIES_RELEASES_PATH.matches(next.encodedPath)
if (next.host != base.host || (!isReposPath && !isRepositoriesPath)) {
throw IOException("GitHub returned an untrusted pagination URL")
}
return next.toString()
@@ -340,6 +343,7 @@ open class AppUpdateChecker(
const val RELEASE_LIST_PAGE_SIZE = 100
val NEXT_LINK = Regex("<([^>]+)>;\\s*rel=\"next\"")
val REPOSITORIES_RELEASES_PATH = Regex("^/repositories/[0-9]+/releases$")
}
}
@@ -7,6 +7,20 @@ 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 com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsParams
import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsRemoveParams
import com.m57.hermescontrol.data.ws.contract.ConnectorsCatalogParams
import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams
import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams
import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams
import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicyGetParams
import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicySetParams
import com.m57.hermescontrol.data.ws.contract.ConnectorsToolsParams
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -48,10 +62,9 @@ interface AccountConnectorRepository {
expectedRevision: String,
): AccountConnectorResult<ConnectorPolicy>
suspend fun operationRequest(
method: String,
params: Map<String, Any>,
): Any?
suspend fun operationRespond(params: ConnectionRespondParams): Any?
suspend fun operationWake(params: ConnectorsOperationStatusParams): Any?
suspend fun operationStatus(
opId: String,
@@ -61,25 +74,8 @@ interface AccountConnectorRepository {
}
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()
}
},
private val caller: TypedRpcCaller = HermesRpcCaller,
) : 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())
@@ -97,12 +93,6 @@ class HermesAccountConnectorRepository(
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()
@@ -116,10 +106,11 @@ class HermesAccountConnectorRepository(
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")),
caller.call(
RpcMethods.CONNECTORS_LIST,
ConnectorsListParams(
owner = ConnectorOwner.account(),
profile = AuthManager.activeProfileId.value,
),
),
)
@@ -146,27 +137,48 @@ class HermesAccountConnectorRepository(
) = 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,
),
caller.call(
RpcMethods.CONNECTORS_CONNECT,
ConnectorsConnectParams(
owner = ConnectorOwner.account(),
connectors = slugs,
reconnect = reconnect,
profile = AuthManager.activeProfileId.value,
),
),
)
}
override suspend fun operationRequest(
method: String,
params: Map<String, Any>,
): Any? = request(method, accountParams(params + ("owner" to mapOf("type" to "account"))))
override suspend fun operationRespond(params: ConnectionRespondParams): Any? =
caller.call(
RpcMethods.CONNECTION_RESPOND,
params.copy(
owner = ConnectorOwner.account(),
profile = AuthManager.activeProfileId.value,
),
)
override suspend fun operationWake(params: ConnectorsOperationStatusParams): Any? =
caller.call(
RpcMethods.CONNECTORS_OPERATION_WAKE,
params.copy(
owner = ConnectorOwner.account(),
profile = AuthManager.activeProfileId.value,
),
)
override suspend fun operationStatus(opId: String) =
guarded {
parseOperation(operationRequest(WsMethods.CONNECTORS_OPERATION_STATUS, mapOf("op_id" to opId)))
parseOperation(
caller.call(
RpcMethods.CONNECTORS_OPERATION_STATUS,
ConnectorsOperationStatusParams(
owner = ConnectorOwner.account(),
opId = opId,
profile = AuthManager.activeProfileId.value,
),
),
)
}
@Suppress("UNCHECKED_CAST")
@@ -180,7 +192,14 @@ class HermesAccountConnectorRepository(
override suspend fun catalog() =
guarded {
obj(request(WsMethods.CONNECTORS_CATALOG, accountParams()))["connectors"]!!.jsonArray.map {
val response =
caller.call(
RpcMethods.CONNECTORS_CATALOG,
ConnectorsCatalogParams(
profile = AuthManager.activeProfileId.value,
),
)
obj(response)["connectors"]!!.jsonArray.map {
val row = it.jsonObject
ConnectorCatalogEntry(row.str("slug"), row.str("name"), row.str("description"), row.str("category"))
}
@@ -188,7 +207,14 @@ class HermesAccountConnectorRepository(
override suspend fun accounts() =
guarded {
obj(request(WsMethods.CONNECTORS_ACCOUNTS, accountParams()))["accounts"]!!.jsonArray.map { e ->
val response =
caller.call(
RpcMethods.CONNECTORS_ACCOUNTS,
ConnectorsAccountsParams(
profile = AuthManager.activeProfileId.value,
),
)
obj(response)["accounts"]!!.jsonArray.map { e ->
val r = e.jsonObject
ConnectorAccount(
r.str("connection_id"),
@@ -209,10 +235,11 @@ class HermesAccountConnectorRepository(
require(connectionId.isNotBlank())
val response =
obj(
request(
WsMethods.CONNECTORS_ACCOUNTS_REMOVE,
accountParams(
mapOf("connection_id" to connectionId),
caller.call(
RpcMethods.CONNECTORS_ACCOUNTS_REMOVE,
ConnectorsAccountsRemoveParams(
connectionId = connectionId,
profile = AuthManager.activeProfileId.value,
),
),
)
@@ -225,12 +252,16 @@ class HermesAccountConnectorRepository(
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 response =
caller.call(
RpcMethods.CONNECTORS_TOOLS,
ConnectorsToolsParams(
slug = slug,
refresh = if (refresh) true else null,
profile = AuthManager.activeProfileId.value,
),
)
obj(response)["tools"]!!.jsonArray.map { e ->
val r = e.jsonObject
ConnectorTool(
r.str("slug"),
@@ -245,7 +276,15 @@ class HermesAccountConnectorRepository(
override suspend fun policy() =
guarded {
val root = obj(request(WsMethods.CONNECTORS_POLICY_GET, accountParams()))
val root =
obj(
caller.call(
RpcMethods.CONNECTORS_POLICY_GET,
ConnectorsPolicyGetParams(
profile = AuthManager.activeProfileId.value,
),
),
)
val layers = root.array("layers").map { it.jsonObject }
val member = layers.firstOrNull { it.str("kind") == "member" }
parsePolicy(root["effective"]!!.jsonObject).copy(
@@ -293,13 +332,12 @@ class HermesAccountConnectorRepository(
check(scope == AuthManager.currentDataScope()) { "Account scope changed" }
val result =
obj(
request(
WsMethods.CONNECTORS_POLICY_SET,
accountParams(
mapOf(
"change" to change,
"expected_revision" to expectedRevision,
),
caller.call(
RpcMethods.CONNECTORS_POLICY_SET,
ConnectorsPolicySetParams(
change = ConnectorParser.toJsonElement(change) as JsonObject,
expectedRevision = expectedRevision,
profile = AuthManager.activeProfileId.value,
),
),
)
@@ -8,6 +8,11 @@ import com.m57.hermescontrol.data.model.SubscriptionStateResponse
import com.m57.hermescontrol.data.model.SubscriptionUpgradeResponse
import com.m57.hermescontrol.data.model.UsageBarsResponse
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.ws.contract.EmptyParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SubscriptionChangeParams
import com.m57.hermescontrol.data.ws.contract.SubscriptionPreviewParams
import com.m57.hermescontrol.data.ws.contract.SubscriptionUpgradeParams
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
@@ -90,50 +95,47 @@ object BillingRepository {
}
suspend fun getSubscriptionState(): SubscriptionStateResponse? {
val result = HermesWsClient.request(WsMethods.SUBSCRIPTION_STATE).await()
val result = HermesWsClient.call(RpcMethods.SUBSCRIPTION_STATE, EmptyParams)
return decode(result)
}
suspend fun getUsageBars(): UsageBarsResponse? {
val result = HermesWsClient.request(WsMethods.USAGE_BARS).await()
val result = HermesWsClient.call(RpcMethods.USAGE_BARS, EmptyParams)
return decode(result)
}
suspend fun previewSubscription(subscriptionTypeId: String): SubscriptionPreviewResponse? {
val result =
HermesWsClient
.request(
WsMethods.SUBSCRIPTION_PREVIEW,
mapOf("subscription_type_id" to subscriptionTypeId),
).await()
HermesWsClient.call(
RpcMethods.SUBSCRIPTION_PREVIEW,
SubscriptionPreviewParams(subscriptionTypeId = subscriptionTypeId),
)
return decode(result)
}
suspend fun changeSubscription(request: SubscriptionChangeRequest): SubscriptionChangeResponse? {
val result =
HermesWsClient
.request(
WsMethods.SUBSCRIPTION_CHANGE,
buildMap {
request.subscription_type_id?.let { put("subscription_type_id", it) }
request.cancel?.let { put("cancel", it) }
},
).await()
HermesWsClient.call(
RpcMethods.SUBSCRIPTION_CHANGE,
SubscriptionChangeParams(
subscriptionTypeId = request.subscription_type_id,
cancel = request.cancel,
),
)
return decode(result)
}
suspend fun resumeSubscription(): SubscriptionResumeResponse? {
val result = HermesWsClient.request(WsMethods.SUBSCRIPTION_RESUME).await()
val result = HermesWsClient.call(RpcMethods.SUBSCRIPTION_RESUME, EmptyParams)
return decode(result)
}
suspend fun upgradeSubscription(subscriptionTypeId: String): SubscriptionUpgradeResponse? {
val result =
HermesWsClient
.request(
WsMethods.SUBSCRIPTION_UPGRADE,
mapOf("subscription_type_id" to subscriptionTypeId),
).await()
HermesWsClient.call(
RpcMethods.SUBSCRIPTION_UPGRADE,
SubscriptionUpgradeParams(subscriptionTypeId = subscriptionTypeId),
)
return decode(result)
}
}
@@ -0,0 +1,140 @@
package com.m57.hermescontrol.data.ws
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
internal class ConnectionHealth(
private val scope: CoroutineScope,
private val isConnected: () -> Boolean,
private val pingRequest: suspend (Long) -> Unit,
private val cancelSocket: () -> Unit,
private val pendingReply: () -> Boolean,
private val nowMs: () -> Long = { System.nanoTime() / 1_000_000L },
private val log: (String) -> Unit = {},
private val debugLog: (String) -> Unit = {},
) {
companion object {
internal const val HEARTBEAT_INTERVAL_MS = 15_000L
internal const val STALE_THRESHOLD_MS = 30_000L
internal const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L
}
@Volatile
var lastPongTimestamp: Long = 0L
internal set
private val _lastLatencyMs = MutableStateFlow<Long?>(null)
val lastLatencyMs: StateFlow<Long?> = _lastLatencyMs.asStateFlow()
val isHealthy: Boolean
get() = isConnected() && (nowMs() - lastPongTimestamp < STALE_THRESHOLD_MS)
private var healthJob: Job? = null
private var foregroundProbeJob: Job? = null
private var transportProbeJob: Job? = null
fun start() {
healthJob?.cancel()
lastPongTimestamp = nowMs()
healthJob =
scope.launch {
while (isConnected()) {
delay(HEARTBEAT_INTERVAL_MS)
if (isConnected()) {
try {
ping(timeoutMs = LIVENESS_PROBE_TIMEOUT_MS)
} catch (e: Exception) {
log("Health check ping failed: ${e.message}")
}
}
if (!runHealthCheckPass()) break
}
}
}
fun stop() {
healthJob?.cancel()
healthJob = null
_lastLatencyMs.value = null
}
fun onInboundFrame() {
lastPongTimestamp = nowMs()
}
suspend fun ping(timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS): Long {
val start = nowMs()
pingRequest(timeoutMs)
val latency = (nowMs() - start).coerceAtLeast(0L)
lastPongTimestamp = nowMs()
_lastLatencyMs.value = latency
return latency
}
@VisibleForTesting
internal fun setLastLatencyForTest(latency: Long?) {
_lastLatencyMs.value = latency
}
private fun runHealthCheckPass(): Boolean {
if (!isConnected()) return false
val staleMs = nowMs() - lastPongTimestamp
if (staleMs <= STALE_THRESHOLD_MS) return true
log("WebSocket stale (${staleMs / 1000}s without frames) — cancelling to trigger reconnect")
cancelSocket()
return false
}
@VisibleForTesting
internal fun forceHealthCheckForTest(staleMillis: Long) {
lastPongTimestamp = nowMs() - staleMillis
runHealthCheckPass()
}
fun probeLivenessOnWake(deferredOnce: Boolean = false) {
if (!isConnected()) return
foregroundProbeJob?.cancel()
foregroundProbeJob =
scope.launch {
val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess
if (alive) return@launch
if (pendingReply() && !deferredOnce) {
delay(3_000L)
probeLivenessOnWake(deferredOnce = true)
return@launch
}
log("Foreground liveness probe failed — cancelling socket")
cancelSocket()
}
}
fun cancelForegroundProbe() {
foregroundProbeJob?.cancel()
foregroundProbeJob = null
}
fun probeLivenessOnTransportChange(
timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS,
onFailureAction: () -> Unit = cancelSocket,
) {
if (!isConnected()) return
transportProbeJob?.cancel()
transportProbeJob =
scope.launch {
debugLog("Network transport changed — probing WebSocket liveness")
val alive = runCatching { ping(timeoutMs) }.isSuccess
if (alive) {
debugLog("WebSocket liveness probe succeeded on new transport")
return@launch
}
log("Transport change liveness probe failed — cancelling socket")
onFailureAction()
}
}
}
@@ -3,6 +3,12 @@ package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ConnectorConnectResult
import com.m57.hermescontrol.data.model.ConnectorError
import com.m57.hermescontrol.data.model.ConnectorListResult
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams
import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
import kotlinx.coroutines.CancellationException
/**
@@ -42,19 +48,7 @@ interface ConnectorRepository {
* Default implementation of [ConnectorRepository] backed by [HermesWsClient].
*/
class HermesConnectorRepository(
private val rpcRequest: suspend (method: String, params: 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()
}
}
},
private val caller: TypedRpcCaller = HermesRpcCaller,
) : ConnectorRepository {
override suspend fun listConnectors(sessionId: String): ConnectorListResult {
if (sessionId.isBlank()) {
@@ -64,8 +58,8 @@ class HermesConnectorRepository(
}
return try {
val params = mapOf("owner" to ConnectorRepository.sessionOwner(sessionId))
val result = rpcRequest(WsMethods.CONNECTORS_LIST, params)
val params = ConnectorsListParams(owner = ConnectorOwner.session(sessionId))
val result = caller.call(RpcMethods.CONNECTORS_LIST, params)
ConnectorParser.parseListResult(result)
} catch (e: CancellationException) {
throw e
@@ -107,12 +101,12 @@ class HermesConnectorRepository(
return try {
val params =
mapOf(
"owner" to ConnectorRepository.sessionOwner(sessionId),
"connectors" to connectors,
"reconnect" to reconnect,
ConnectorsConnectParams(
owner = ConnectorOwner.session(sessionId),
connectors = connectors,
reconnect = reconnect,
)
val result = rpcRequest(WsMethods.CONNECTORS_CONNECT, params)
val result = caller.call(RpcMethods.CONNECTORS_CONNECT, params)
ConnectorParser.parseConnectResult(result)
} catch (e: CancellationException) {
throw e
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.model.parseMessageReactions
import com.m57.hermescontrol.ui.chat.extractTodosFromMap
import kotlinx.serialization.json.JsonObject
@@ -333,6 +334,20 @@ object EventParser {
WsEvent.ReactionEvent(kind)
}
"message.reaction" -> {
val rowId = (payload?.get("row_id") as? Number)?.toLong()
if (rowId == null) {
WsEvent.Unknown(rawJson)
} else {
WsEvent.MessageReactionUpdated(
rowId = rowId,
reactions = parseMessageReactions(payload["reactions"]),
role = payload["role"] as? String ?: "",
sessionId = sessionId,
)
}
}
"approval.request" -> {
val command = payload?.get("command") as? String
val description = payload?.get("description") as? String
File diff suppressed because it is too large. Load diff
@@ -5,6 +5,8 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.contract.ModelOptionsParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
@@ -16,13 +18,8 @@ class ModelOptionsRepository(
private val connected: () -> Boolean = {
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
},
private val request: suspend (Map<String, Any>) -> Any? = { params ->
val deferred = HermesWsClient.request(WsMethods.MODEL_OPTIONS, params)
try {
deferred.await()
} finally {
if (!deferred.isCompleted) deferred.cancel()
}
private val request: suspend (ModelOptionsParams) -> JsonElement = { params ->
HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params)
},
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
@@ -32,9 +29,7 @@ class ModelOptionsRepository(
currentCoroutineContext().ensureActive()
if (connected()) {
try {
val result = request(mapOf("refresh" to refresh, "include_unconfigured" to false))
// request() returns raw JsonElement, unlike the pushed-event Map representation.
val json = result as? JsonElement ?: error("Invalid model.options result")
val json = request(ModelOptionsParams(refresh = refresh, includeUnconfigured = false))
return NetworkResult.Success(OkHttpProvider.json.decodeFromJsonElement<ModelOptionsResponse>(json))
} catch (e: CancellationException) {
// A disconnected socket may cancel the independent RPC deferred. Only fall back
@@ -1,8 +1,10 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
import com.m57.hermescontrol.data.ws.contract.PluginsManageParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
@@ -11,23 +13,14 @@ 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
}
},
private val caller: TypedRpcCaller = HermesRpcCaller,
) {
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 element =
caller.call(
RpcMethods.PLUGINS_MANAGE,
PluginsManageParams(action = "list", profile = profile.orNullIfBlank()),
)
val pluginsElement = (element as? JsonObject)?.get("plugins") ?: return emptyList()
return OkHttpProvider.json.decodeFromJsonElement<List<AgentPluginRow>>(pluginsElement)
}
@@ -38,15 +31,15 @@ class PluginManageRepository(
profile: String? = null,
): PluginsManageSettingsResult {
val params =
mutableMapOf<String, Any?>(
"action" to "settings",
"key" to key,
"values" to JsonObject(values),
PluginsManageParams(
action = "settings",
key = key,
values = JsonObject(values),
profile = profile.orNullIfBlank(),
)
if (!profile.isNullOrBlank()) {
params["profile"] = profile
}
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params)
return OkHttpProvider.json.decodeFromJsonElement<PluginsManageSettingsResult>(result.toJsonElement())
val result = caller.call(RpcMethods.PLUGINS_MANAGE, params)
return OkHttpProvider.json.decodeFromJsonElement<PluginsManageSettingsResult>(result)
}
private fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
}
@@ -1,6 +1,9 @@
package com.m57.hermescontrol.data.ws
import kotlinx.coroutines.CancellationException
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
import com.m57.hermescontrol.data.ws.contract.PluginsManageParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
@@ -14,18 +17,10 @@ data class PluginRemovalResult(
/** Removes a user-installed plugin through the gateway's canonical plugin manager. */
class PluginRemovalRepository(
private val rpcRequest: 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
}
},
private val caller: TypedRpcCaller = HermesRpcCaller,
) {
suspend fun remove(name: String): PluginRemovalResult {
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, mapOf("action" to "remove", "name" to name))
return Json.decodeFromJsonElement<PluginRemovalResult>(result.toJsonElement())
val result = caller.call(RpcMethods.PLUGINS_MANAGE, PluginsManageParams(action = "remove", name = name))
return Json.decodeFromJsonElement<PluginRemovalResult>(result)
}
}
@@ -3,6 +3,10 @@ package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ProjectInfo
import com.m57.hermescontrol.data.model.ProjectsListResponse
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
import com.m57.hermescontrol.data.ws.contract.ProjectsListParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.JsonObject
@@ -17,14 +21,12 @@ class HermesProjectsSource(
// Only ask a live socket: an offline request would be queued and force a reconnect on
// every refresh. The view model refetches when the connection comes back.
private val isConnected: () -> Boolean = { HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED },
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
HermesWsClient.request(method, params).await()
},
private val caller: TypedRpcCaller = HermesRpcCaller,
) : ProjectsSource {
override suspend fun fetchProjects(): List<ProjectInfo>? {
if (!isConnected()) return null
return try {
val element = SessionLiveStatusDecoder.anyToJsonElement(rpcRequest(WsMethods.PROJECTS_LIST, emptyMap()))
val element = caller.call(RpcMethods.PROJECTS_LIST, ProjectsListParams)
if (element is JsonObject && element.containsKey("projects")) {
OkHttpProvider.json.decodeFromJsonElement(ProjectsListResponse.serializer(), element).projects
} else {
@@ -0,0 +1,39 @@
package com.m57.hermescontrol.data.ws
internal class ReconnectPolicy(
private val random: () -> Double = { Math.random() },
) {
@Volatile
var currentBackoff: Long = INITIAL_BACKOFF_MS
private var initialBackoffMs: Long = INITIAL_BACKOFF_MS
private var testBackoffOverrideMs: Long? = null
fun nextDelayMs(): Long {
val override = testBackoffOverrideMs
if (override != null) return override
val ceiling = currentBackoff
if (ceiling <= 0L) return 0L
return (random() * ceiling).toLong().coerceAtLeast(BACKOFF_BASE_MS).coerceAtMost(ceiling)
}
fun advance() {
currentBackoff = (currentBackoff * BACKOFF_MULTIPLIER).toLong().coerceAtMost(MAX_BACKOFF_MS)
}
fun reset() {
currentBackoff = initialBackoffMs
}
fun setBackoffForTest(initialMillis: Long) {
initialBackoffMs = initialMillis
currentBackoff = initialMillis
testBackoffOverrideMs = initialMillis
}
companion object {
private const val INITIAL_BACKOFF_MS = 1_000L
private const val BACKOFF_BASE_MS = 300L
private const val MAX_BACKOFF_MS = 30_000L
private const val BACKOFF_MULTIPLIER = 2.0
}
}
@@ -0,0 +1,245 @@
package com.m57.hermescontrol.data.ws
import androidx.annotation.VisibleForTesting
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
/**
* Tracks session event sequence watermarks and orchestrates event replay on reconnect.
*
* Extracted from [HermesWsClient] (issue #1016, issue #1376).
*/
internal class ReplayTracker(
private val scope: CoroutineScope,
private val fetchEventsSince: suspend (SessionEventsSinceParams) -> SessionEventsSinceResult,
private val emitEvent: (WsEvent) -> Unit,
private val resolveStoredSessionId: (String?) -> String? = { it },
private val onReplayFailure: (String, Exception) -> Unit = { _, _ -> },
) {
private val lastSeenSeq = ConcurrentHashMap<String, Int>()
@Volatile
private var replayEpoch: String? = null
@Volatile
private var replayInFlight: Boolean = false
private val replayHold = ConcurrentHashMap<String, MutableList<Pair<Int?, WsEvent>>>()
@Volatile
private var replayJob: Job? = null
/**
* Synchronously arms the hold buffers for all known sessions and launches replay in [scope].
* Arming synchronously ensures racing live frames are held instead of advancing watermarks.
*/
fun triggerReplay() {
if (lastSeenSeq.isEmpty() || replayInFlight) return
replayInFlight = true
val sessionsToReplay = lastSeenSeq.keys().toList()
for (sid in sessionsToReplay) {
replayHold[sid] = Collections.synchronizedList(mutableListOf())
}
replayJob?.cancel()
replayJob =
scope.launch {
fetchReplay(sessionsToReplay)
}
}
/**
* Updates replay epoch from gateway.ready.
* When [epoch] is non-empty and changes from a previous non-null value, resets sequence watermarks.
*/
fun updateEpoch(epoch: String?) {
if (!epoch.isNullOrEmpty()) {
if (replayEpoch != null && replayEpoch != epoch) {
lastSeenSeq.clear()
}
replayEpoch = epoch
}
}
/**
* Checks if an incoming event is a duplicate against the current watermark for [sessionId].
* Returns true if [seq] <= watermark.
*/
fun isDuplicate(
sessionId: String,
seq: Int,
): Boolean {
val prev = lastSeenSeq[sessionId] ?: 0
return seq <= prev
}
/**
* Processes an incoming live event with sequence tracking.
* Evaluates [eventFactory] only after checking for duplicates.
* If duplicate, drops it without calling [eventFactory].
* If replay is in flight for [sessionId], buffers it in replayHold.
* Otherwise advances watermark and emits the event.
*/
fun acceptLiveEvent(
sessionId: String,
seq: Int,
eventFactory: () -> WsEvent,
) {
if (isDuplicate(sessionId, seq)) {
return
}
val finalEvent = decorateEvent(eventFactory())
if (replayInFlight && replayHold.containsKey(sessionId)) {
replayHold[sessionId]?.add(seq to finalEvent)
return
}
lastSeenSeq[sessionId] = seq
emitEvent(finalEvent)
}
/**
* Processes an incoming live event with sequence tracking.
* If duplicate, drops it.
* If replay is in flight for [sessionId], buffers it in replayHold.
* Otherwise advances watermark and emits the event.
*/
fun acceptLiveEvent(
sessionId: String,
seq: Int,
event: WsEvent,
) {
acceptLiveEvent(sessionId, seq) { event }
}
private fun decorateEvent(event: WsEvent): WsEvent =
if (event is WsEvent.MessageComplete) {
event.copy(
storedSessionId = resolveStoredSessionId(event.sessionId),
)
} else {
event
}
private suspend fun fetchReplay(sessionsToReplay: List<String> = lastSeenSeq.keys().toList()) {
try {
for (sid in sessionsToReplay) {
val lastSeen = lastSeenSeq[sid] ?: continue
try {
val res =
fetchEventsSince(
SessionEventsSinceParams(sessionId = sid, lastSeen = lastSeen),
)
val epoch = res.epoch
val epochChanged =
!epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch
val truncated = res.truncated == true
val latestSeq = res.latestSeq
if (epochChanged) replayEpoch = epoch
if (epochChanged || truncated) {
// Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted).
// Partial replay would silently hole the transcript — fast-forward watermark
// and request full transcript resync.
if (epochChanged) lastSeenSeq.clear()
if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq
emitEvent(WsEvent.TranscriptResyncRequired(sid))
continue
}
val eventsList = res.events
if (eventsList != null) {
for (element in eventsList) {
@Suppress("UNCHECKED_CAST")
val eventMap = (element as? JsonObject)?.toAny() as? Map<String, Any?> ?: continue
val eventSeq = (eventMap["seq"] as? Number)?.toInt()
val eventSid = (eventMap["session_id"] as? String) ?: sid
if (eventSeq != null && eventSid.isNotEmpty()) {
val prev = lastSeenSeq[eventSid] ?: 0
if (eventSeq <= prev) continue
lastSeenSeq[eventSid] = eventSeq
}
val parsedEvent = EventParser.parseParams(eventMap)
val finalEvent = decorateEvent(parsedEvent)
emitEvent(finalEvent)
}
}
} catch (e: Exception) {
onReplayFailure(sid, e)
emitEvent(WsEvent.TranscriptResyncRequired(sid))
}
}
} finally {
withContext(NonCancellable) {
for (sid in sessionsToReplay) {
val held = replayHold.remove(sid)
if (held != null) {
synchronized(held) {
for ((heldSeq, heldEvent) in held) {
if (heldSeq != null) {
val prev = lastSeenSeq[sid] ?: 0
if (heldSeq <= prev) continue
lastSeenSeq[sid] = heldSeq
}
emitEvent(heldEvent)
}
}
}
}
replayInFlight = false
}
}
}
/**
* Complete teardown matching disconnect (issue #1016).
* Cancels any active replay job and clears all holds, watermarks, and epoch.
*/
fun clear() {
lastSeenSeq.clear()
replayHold.clear()
replayEpoch = null
replayJob?.cancel()
replayJob = null
replayInFlight = false
}
@VisibleForTesting
internal fun getSeqWatermarks(): Map<String, Int> = HashMap(lastSeenSeq)
@VisibleForTesting
internal fun setSeqWatermark(
sessionId: String,
seq: Int,
) {
lastSeenSeq[sessionId] = seq
}
@VisibleForTesting
internal fun clearSeqWatermarks() {
lastSeenSeq.clear()
replayHold.clear()
replayEpoch = null
replayInFlight = false
}
@VisibleForTesting
internal fun isReplayInFlight(): Boolean = replayInFlight
@VisibleForTesting
internal fun setReplayEpochForTest(epoch: String?) {
replayEpoch = epoch
}
@VisibleForTesting
internal suspend fun fetchReplayForTest() {
fetchReplay()
}
}
@@ -0,0 +1,353 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.ws.contract.RpcMethod
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
/**
* Socket-independent JSON-RPC 2.0 request correlation and response dispatcher.
* Manages request IDs, in-flight pending calls, timeouts, cancellation cleanup,
* and server response frame formatting.
*/
internal class RpcChannel(
private val scope: CoroutineScope,
private val json: Json,
private val sendRequest: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> String,
private val sendFrame: (String) -> Boolean,
private val removeQueuedMessage: (String) -> Unit,
private val onIdleCheck: () -> Unit,
private val logRejected: (method: String, id: String) -> Unit = { _, _ -> },
private val outboundLock: Any = Any(),
private val onResult: (String) -> Unit = {},
private val onError: (String) -> Unit = {},
private val resolveStoredSessionId: (String?) -> String? = { it },
private val logParseFailure: (Exception) -> Unit = {},
private val decorateParams: (String, Map<String, Any>) -> Map<String, Any> = { _, p -> p },
) {
/** A single in-flight [request] awaiting its RPC result/error. */
private data class PendingCall(
val method: String,
val deferred: CompletableDeferred<Any?>,
var timeoutJob: Job? = null,
val suppressErrorEvent: Boolean = false,
)
private val requestId = AtomicInteger(0)
private val pendingCalls = ConcurrentHashMap<String, PendingCall>()
private val capabilityRequestIds = ConcurrentHashMap.newKeySet<String>()
fun nextId(): String = requestId.incrementAndGet().toString()
fun hasPendingCalls(): Boolean = pendingCalls.isNotEmpty()
fun pendingCallIdsForTest(): Set<String> = pendingCalls.keys.toSet()
fun pendingCallTimeoutForTest(id: String): Job? = pendingCalls[id]?.timeoutJob
fun isErrorEventSuppressed(id: String): Boolean = pendingCalls[id]?.suppressErrorEvent == true
fun registerCapabilityRequest(id: String) {
capabilityRequestIds.add(id)
}
fun clearCapabilityRequests() {
capabilityRequestIds.clear()
}
fun consumeCapabilityResponse(event: WsEvent): Boolean {
val id =
when (event) {
is WsEvent.RpcResult -> event.id
is WsEvent.RpcError -> event.id
else -> return false
}
val isCapabilityResponse =
synchronized(outboundLock) {
capabilityRequestIds.remove(id)
}
if (!isCapabilityResponse) return false
removeQueuedMessage(id)
return true
}
fun createRequest(
method: String,
params: Map<String, Any>,
): JsonRpcRequest {
val id = nextId()
val decorated = decorateParams(method, params)
return JsonRpcRequest(
id = id,
method = method,
params = decorated.mapValues { it.value.toJsonElement() },
)
}
fun encodeRequest(request: JsonRpcRequest): String = json.encodeToString(request)
/**
* Send a JSON-RPC request that expects a result and returns a [CompletableDeferred] for it.
*/
fun request(
method: String,
params: Map<String, Any> = emptyMap(),
timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS,
suppressErrorEvent: Boolean = false,
): CompletableDeferred<Any?> {
val deferred = CompletableDeferred<Any?>()
val id =
sendRequest(method, params) { reqId ->
pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent)
}
deferred.invokeOnCompletion { cause ->
if (cause is CancellationException) {
pendingCalls.remove(id)?.let { call ->
call.timeoutJob?.cancel()
removeQueuedMessage(id)
onIdleCheck()
}
}
}
// Arm the per-request timeout (fires if the server never answers).
pendingCalls[id]?.timeoutJob =
scope.launch {
delay(timeoutMs)
resolvePending(id, null, JsonRpcError(-1, "Request timed out: $method"))
}
return deferred
}
/**
* Non-suspending typed twin of [request] for callers that must enqueue inside a
* non-suspending owner check and await the raw result themselves.
*/
fun <P, R> requestTyped(
method: RpcMethod<P, R>,
params: P,
timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS,
suppressErrorEvent: Boolean = false,
): CompletableDeferred<Any?> = request(method.name, encodeParams(method, params), timeoutMs, suppressErrorEvent)
/**
* Send a typed JSON-RPC request and await its deserialized result.
*/
suspend fun <P, R> call(
method: RpcMethod<P, R>,
params: P,
timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS,
suppressErrorEvent: Boolean = false,
): R = awaitResult(method, requestTyped(method, params, timeoutMs, suppressErrorEvent))
suspend fun <P, R> awaitResult(
method: RpcMethod<P, R>,
deferred: CompletableDeferred<Any?>,
): R {
val result =
try {
deferred.await()
} catch (e: CancellationException) {
deferred.cancel(e)
throw e
}
val element = result.toJsonElement()
return json.decodeFromJsonElement(method.result, element)
}
fun <P, R> encodeParams(
method: RpcMethod<P, R>,
params: P,
): JsonObject {
val encoded = json.encodeToJsonElement(method.params, params)
require(encoded is JsonObject) {
"RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}"
}
return encoded
}
/** Complete (or fail) a single pending call and cancel its timer. */
fun resolvePending(
id: String,
result: Any?,
error: JsonRpcError?,
) {
val call = pendingCalls.remove(id) ?: return
removeQueuedMessage(id)
call.timeoutJob?.cancel()
if (error != null) {
call.deferred.completeExceptionally(
HermesWsClient.HermesRpcException(
message = error.message,
code = error.code,
data = error.data,
),
)
} else {
call.deferred.complete(result)
}
onIdleCheck()
}
/**
* Fail and clear every in-flight [request].
*/
fun rejectAllPending(
error: HermesWsClient.HermesRpcException =
HermesWsClient.HermesRpcException("Connection lost — request cancelled"),
) {
if (pendingCalls.isEmpty()) return
val snapshot = pendingCalls.toList()
pendingCalls.clear()
for ((id, call) in snapshot) {
removeQueuedMessage(id)
logRejected(call.method, id)
call.timeoutJob?.cancel()
call.deferred.completeExceptionally(error)
}
}
/**
* Answer a gateway-originated JSON-RPC request.
*/
fun respondToServerRequest(
id: String,
result: JsonElement,
): Boolean {
if (id.isBlank()) return false
val frameJson =
buildJsonObject {
put("jsonrpc", "2.0")
put("id", id)
put("result", result)
}.toString()
return sendFrame(frameJson)
}
/** Answer a gateway-originated request with a standard JSON-RPC error. */
fun respondToServerRequestError(
id: String,
code: Int,
message: String,
): Boolean {
if (id.isBlank()) return false
val frameJson =
buildJsonObject {
put("jsonrpc", "2.0")
put("id", id)
put(
"error",
buildJsonObject {
put("code", code)
put("message", message)
},
)
}.toString()
return sendFrame(frameJson)
}
/**
* Handles an incoming WebSocket text frame: JSON decoding, request correlation,
* server request dispatch, sequence deduplication via [replayTracker], and event emission.
*/
fun handleIncomingFrame(
text: String,
replayTracker: ReplayTracker,
emitEvent: (WsEvent) -> Unit,
) {
val event =
try {
val rpc = json.decodeFromString<JsonRpcResponse>(text)
// Resolve pending request() calls with the RAW JsonElement from
// rpc.result, BEFORE EventParser.parse() converts it via toAny().
// This preserves integer/float type fidelity needed by callers
// that re-deserialize the result into typed data classes.
val rpcId = rpc.id
if (rpcId != null && rpc.error == null && rpc.result != null) {
onResult(rpcId)
removeQueuedMessage(rpcId)
resolvePending(rpcId, rpc.result, null)
}
// Resume/replay responses carry still-open server requests
// separately from the event ring. Re-deliver them through the
// same generic path before exposing the RPC result.
@Suppress("UNCHECKED_CAST")
val openRequests =
(rpc.result?.toAny() as? Map<*, *>)?.get("open_requests") as? List<Map<*, *>>
openRequests?.forEach { openRequest ->
val openId = openRequest["id"] as? String ?: return@forEach
val method = openRequest["method"] as? String ?: return@forEach
@Suppress("UNCHECKED_CAST")
val params = openRequest["params"] as? Map<String, Any?> ?: emptyMap()
emitEvent(WsEvent.ServerRequest(openId, method, params, replayed = true))
}
if (rpc.id == null) {
// Issue #1163: inspect scalars, not a throwaway copy of the whole params tree.
val sid = rpc.params?.eventSessionId()
val seq = ((rpc.params?.get("seq") as? JsonPrimitive)?.toAny() as? Number)?.toInt()
if (!sid.isNullOrBlank() && seq != null) {
replayTracker.acceptLiveEvent(sid, seq) {
EventParser.parse(rpc, text)
}
return
}
}
val parsed = EventParser.parse(rpc, text)
if (parsed is WsEvent.MessageComplete) {
parsed.copy(
storedSessionId = resolveStoredSessionId(parsed.sessionId),
)
} else {
parsed
}
} catch (e: Exception) {
logParseFailure(e)
WsEvent.Unknown(text)
}
if (consumeCapabilityResponse(event)) return
when (event) {
is WsEvent.RpcResult -> {
onResult(event.id)
removeQueuedMessage(event.id)
resolvePending(event.id, event.result, null)
}
is WsEvent.RpcError -> {
onError(event.id)
if (pendingCalls[event.id]?.suppressErrorEvent == true) {
// Opt-in suppression: the caller already handles the
// failure through the CompletableDeferred, so the event
// copy would only surface a duplicate UI banner for
// optional features (e.g. "subagent.list" on gateways
// without the method, issue #1089). Default request()
// behavior still emits the event for shared consumers.
resolvePending(event.id, null, event.error)
return
}
removeQueuedMessage(event.id)
resolvePending(event.id, null, event.error)
}
else -> {}
}
emitEvent(event)
}
}
@@ -3,6 +3,8 @@ package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ActiveSessionsResponse
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
import com.m57.hermescontrol.data.model.SessionLiveStatus
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionActiveListParams
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@@ -148,8 +150,8 @@ object SessionLiveStatusDecoder {
* Production implementation of [SessionLiveStatusSource] backed by [HermesWsClient].
*/
class HermesSessionLiveStatusSource(
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
HermesWsClient.request(method, params).await()
private val rpcRequest: suspend () -> Any? = {
HermesWsClient.call(RpcMethods.SESSION_ACTIVE_LIST, SessionActiveListParams())
},
eventsProvider: () -> Flow<WsEvent> = { HermesWsClient.events },
connectionStatusProvider: () -> StateFlow<ConnectionStatus> = { HermesWsClient.connectionStatus },
@@ -159,7 +161,7 @@ class HermesSessionLiveStatusSource(
override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? =
try {
val raw = rpcRequest(WsMethods.SESSION_ACTIVE_LIST, emptyMap())
val raw = rpcRequest()
SessionLiveStatusDecoder.decodeSnapshot(raw)
} catch (e: CancellationException) {
throw e
@@ -3,6 +3,9 @@ package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.model.SubagentListResponse
import com.m57.hermescontrol.data.model.SubagentTailResponse
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionIdParams
import com.m57.hermescontrol.data.ws.contract.SubagentTailParams
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
@@ -33,12 +36,11 @@ object SubagentRepository {
if (sessionId.isBlank()) return null
return try {
val result =
HermesWsClient
.request(
WsMethods.SUBAGENT_LIST,
mapOf("session_id" to sessionId),
suppressErrorEvent = true,
).await()
HermesWsClient.call(
RpcMethods.SUBAGENT_LIST,
SessionIdParams(sessionId),
suppressErrorEvent = true,
)
decode<SubagentListResponse>(result)
} catch (e: Exception) {
Log.w(TAG, "subagent.list request failed for session $sessionId: ${e.message}")
@@ -55,24 +57,15 @@ object SubagentRepository {
suspend fun tailSubagent(
sessionId: String,
subagentId: String,
maxBytes: Int = 16384,
): SubagentTailResponse? {
if (subagentId.isBlank()) return null
val params =
mutableMapOf<String, Any>(
"subagent_id" to subagentId,
"max_bytes" to maxBytes,
)
if (sessionId.isNotBlank()) {
params["session_id"] = sessionId
}
// Contract (SubagentIdParams): session_id and subagent_id are required, extra keys forbidden (#1379).
if (subagentId.isBlank() || sessionId.isBlank()) return null
val result =
HermesWsClient
.request(
WsMethods.SUBAGENT_TAIL,
params,
suppressErrorEvent = true,
).await()
HermesWsClient.call(
RpcMethods.SUBAGENT_TAIL,
SubagentTailParams(sessionId, subagentId),
suppressErrorEvent = true,
)
return decode<SubagentTailResponse>(result)
}
@@ -8,6 +8,11 @@ import com.m57.hermescontrol.data.model.VaultSourceSetResponse
import com.m57.hermescontrol.data.model.VaultSourcesResponse
import com.m57.hermescontrol.data.model.VaultUnlockResponse
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.ws.contract.EmptyParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.VaultLockParams
import com.m57.hermescontrol.data.ws.contract.VaultSourceSetParams
import com.m57.hermescontrol.data.ws.contract.VaultUnlockParams
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
@@ -48,7 +53,7 @@ object VaultRepository {
}
suspend fun getSources(): List<VaultSource> {
val result = HermesWsClient.request(WsMethods.VAULT_SOURCES).await()
val result = HermesWsClient.call(RpcMethods.VAULT_SOURCES, EmptyParams)
return decode<VaultSourcesResponse>(result)?.sources ?: emptyList()
}
@@ -57,11 +62,10 @@ object VaultRepository {
enabled: Boolean,
): Boolean {
val result =
HermesWsClient
.request(
WsMethods.VAULT_SOURCE_SET,
mapOf("name" to name, "enabled" to enabled),
).await()
HermesWsClient.call(
RpcMethods.VAULT_SOURCE_SET,
VaultSourceSetParams(name = name, enabled = enabled),
)
return decode<VaultSourceSetResponse>(result)?.enabled == enabled
}
@@ -70,22 +74,21 @@ object VaultRepository {
password: String,
): Boolean {
val result =
HermesWsClient
.request(
WsMethods.VAULT_UNLOCK,
mapOf("name" to name, "password" to password),
).await()
HermesWsClient.call(
RpcMethods.VAULT_UNLOCK,
VaultUnlockParams(name = name, password = password),
)
return decode<VaultUnlockResponse>(result)?.unlocked == true
}
suspend fun lockSource(name: String? = null): Boolean {
val params = if (name != null) mapOf("name" to name) else emptyMap<String, Any>()
val result = HermesWsClient.request(WsMethods.VAULT_LOCK, params).await()
val params = VaultLockParams(name = name)
val result = HermesWsClient.call(RpcMethods.VAULT_LOCK, params)
return decode<VaultLockResponse>(result)?.locked == true
}
suspend fun listItems(): List<VaultItem> {
val result = HermesWsClient.request(WsMethods.VAULT_LIST).await()
val result = HermesWsClient.call(RpcMethods.VAULT_LIST, EmptyParams)
return decode<VaultListResponse>(result)?.items ?: emptyList()
}
}
@@ -445,6 +445,18 @@ sealed class WsEvent {
val kind: String = "",
) : WsEvent()
/**
* `message.reaction`: the agent (or another client) set/cleared a tapback on a stored
* message. [reactions] is the row's full list after the write; [rowId] is the gateway
* SQLite row id, matched against `ChatMessage.serverRowId`.
*/
data class MessageReactionUpdated(
val rowId: Long,
val reactions: List<com.m57.hermescontrol.data.model.MessageReaction>,
val role: String,
val sessionId: String?,
) : WsEvent()
// ── Replay resync (internal) ──────────────────────────────────────────
/** Internal: replay could not cover the reconnect gap (truncated or epoch change) — UI must refetch history. */
@@ -94,6 +94,9 @@ object WsMethods {
/** Kill a single background process (scoped to the active session). */
const val PROCESS_KILL = "process.kill"
/** Kill every background process in the gateway registry (`/stop`, global). */
const val PROCESS_STOP = "process.stop"
// ── Subagents (issue #1089) ───────────────────────────────────────────
/** List active subagents and delegations for a session. */
@@ -0,0 +1,912 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.await
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.utf8Size
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
* Socket lifecycle, authentication, outbound queueing, and network transport management.
*
* Extracted from [HermesWsClient] (issue #1376).
*/
internal class WsTransport(
private val scope: CoroutineScope,
private val outboundLock: Any,
private val reconnectPolicy: ReconnectPolicy,
private val connectionHealth: ConnectionHealth,
private val replayTracker: ReplayTracker,
private val rpcChannel: RpcChannel,
private val onRawMessage: (String) -> Unit,
) {
companion object {
private const val TAG = "HermesWsClient"
private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L
}
private val connectionGeneration = AtomicInteger(0)
private val connected = AtomicBoolean(false)
private val intentionalClose = AtomicBoolean(false)
private val acceptQueuedMessages = AtomicBoolean(true)
private val appInForeground = AtomicBoolean(true)
private val externalActivityConnectionLease = AtomicBoolean(false)
private val backgroundConnectionLease = AtomicBoolean(false)
private val messageQueue = ConcurrentLinkedQueue<String>()
private val queuedMessagesById = ConcurrentHashMap<String, String>()
@Volatile
private var webSocket: WebSocket? = null
@Volatile
private var closingSocket: WebSocket? = null
@Volatile
private var reconnectJob: Job? = null
@Volatile
private var outboundDrainJob: Job? = null
private val networkCollectorStarted = AtomicBoolean(false)
private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
val connectionStatus: StateFlow<ConnectionStatus> = _connectionStatus.asStateFlow()
@Volatile
var pendingReply: Boolean = false
internal set
private val pendingPromptSubmits = ConcurrentHashMap.newKeySet<String>()
val isConnected: Boolean get() = connected.get()
fun setAppForeground(foreground: Boolean) {
appInForeground.set(foreground)
if (!foreground) {
disconnectIfIdleInBackground()
return
}
connectionHealth.probeLivenessOnWake()
}
fun acquireExternalActivityConnectionLease() {
externalActivityConnectionLease.set(true)
}
fun releaseExternalActivityConnectionLease() {
externalActivityConnectionLease.set(false)
disconnectIfIdleInBackground()
}
fun acquireBackgroundConnectionLease() {
backgroundConnectionLease.set(true)
}
fun releaseBackgroundConnectionLease() {
backgroundConnectionLease.set(false)
disconnectIfIdleInBackground()
}
internal fun hasBackgroundConnectionLease(): Boolean = backgroundConnectionLease.get()
fun disconnectIfIdleInBackground() {
synchronized(outboundLock) {
if (!appInForeground.get() &&
!externalActivityConnectionLease.get() &&
!backgroundConnectionLease.get() &&
!pendingReply &&
!rpcChannel.hasPendingCalls() &&
messageQueue.isEmpty()
) {
disconnect()
}
}
}
fun connect() {
if (networkCollectorStarted.compareAndSet(false, true)) {
scope.launch {
NetworkMonitor.networkChanges.collect { networkAvailable ->
reconnectForNetworkChange(networkAvailable)
}
}
scope.launch {
NetworkMonitor.transportChanges.collect {
connectionHealth.probeLivenessOnTransportChange(
onFailureAction = { cancelSocket() },
)
}
}
}
var socketToCancel: WebSocket? = null
val shouldOpen =
synchronized(outboundLock) {
acceptQueuedMessages.set(true)
if (_connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) {
connectionGeneration.incrementAndGet()
reconnectJob?.cancel()
reconnectJob = null
outboundDrainJob?.cancel()
outboundDrainJob = null
socketToCancel = webSocket
webSocket = null
closingSocket = null
connected.set(false)
connectionHealth.stop()
}
if (connected.get()) {
Log.d(TAG, "Already connected — skipping")
return@synchronized false
}
if (_connectionStatus.value == ConnectionStatus.CONNECTING ||
_connectionStatus.value == ConnectionStatus.RECONNECTING
) {
Log.d(TAG, "Connection already in flight (${_connectionStatus.value}) — skipping")
return@synchronized false
}
// An explicit connect follows successful authentication and is
// therefore allowed to leave the terminal AUTH_EXPIRED state.
intentionalClose.set(false)
_connectionStatus.value = ConnectionStatus.CONNECTING
reconnectPolicy.reset()
true
}
if (socketToCancel != null) {
rpcChannel.rejectAllPending()
socketToCancel.cancel()
}
if (shouldOpen) openSocket()
}
fun reconnectForNetworkChange(
networkAvailable: Boolean = NetworkMonitor.isConnected.value,
openReplacement: () -> Unit = ::openSocket,
) {
val socketToCancel: WebSocket?
val shouldOpen: Boolean
synchronized(outboundLock) {
if (intentionalClose.get() || _connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) {
return
}
Log.d(TAG, "Default network changed — reconnecting WebSocket")
reconnectPolicy.reset()
reconnectJob?.cancel()
reconnectJob = null
outboundDrainJob?.cancel()
outboundDrainJob = null
connectionGeneration.incrementAndGet()
socketToCancel = webSocket
webSocket = null
closingSocket = null
connected.set(false)
connectionHealth.stop()
shouldOpen = networkAvailable && AuthManager.isAutoReconnect()
_connectionStatus.value =
when {
!networkAvailable -> ConnectionStatus.NO_NETWORK
shouldOpen -> ConnectionStatus.RECONNECTING
else -> ConnectionStatus.DISCONNECTED
}
rpcChannel.rejectAllPending()
}
socketToCancel?.cancel()
if (shouldOpen) openReplacement()
}
suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean {
val isGated =
try {
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
} catch (_: IllegalStateException) {
// serverStore not initialized yet (transient early call); treat
// as loopback so a stale-token refresh still runs. Log so a real
// misconfiguration isn't silently swallowed.
Log.w(TAG, "serverStore uninitialized during WS handshake; assuming non-gated")
false
}
if (!isGated) {
// The loopback dashboard token is regenerated on every server
// restart. Refresh it before each WebSocket handshake so automatic
// reconnect does not get stuck in AUTH_EXPIRED with a stale token.
val token =
runCatching {
DashboardSessionTokenRefresher.refreshAsync()
}.getOrNull()
synchronized(outboundLock) {
if (isCurrentGeneration(generation) && token != null) {
runCatching { AuthManager.setToken(token) }
}
}
return true
}
val ticketResult = requestWsTicket()
if (!ticketResult.ticket.isNullOrBlank()) {
synchronized(outboundLock) {
if (!isCurrentGeneration(generation)) return false
AuthManager.setToken(ticketResult.ticket)
}
if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed")
return true
}
val status =
when {
ticketResult.exception -> ConnectionStatus.RECONNECTING
ticketResult.httpCode == 401 || ticketResult.httpCode == 403 -> ConnectionStatus.AUTH_EXPIRED
ticketResult.httpCode == 408 || ticketResult.httpCode == 429 -> ConnectionStatus.RECONNECTING
ticketResult.httpCode != null && ticketResult.httpCode >= 500 -> ConnectionStatus.RECONNECTING
else -> ConnectionStatus.DISCONNECTED
}
return handleWsTicketRefreshFailure(status, generation)
}
suspend fun mintWsTicket(): String? {
val isGated =
try {
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
} catch (_: IllegalStateException) {
false
}
if (!isGated) {
DashboardSessionTokenRefresher.refreshAsync()
return AuthManager.getToken()
}
return requestWsTicket().ticket
}
private data class TicketRequestResult(
val ticket: String?,
val httpCode: Int?,
val exception: Boolean = false,
)
private suspend fun requestWsTicket(): TicketRequestResult {
try {
val client = OkHttpProvider.probe
val request =
Request
.Builder()
.url(AuthManager.endpointForBuild().resolve("api/auth/ws-ticket").toString())
.post("{}".toRequestBody())
.build()
val (code, body) =
withContext(Dispatchers.IO) {
if (CookieManager.isInitialized()) {
CookieManager.useStore(CookieManager.cookieJar.currentServer())
}
client.newCall(request).await().use { resp ->
resp.code to resp.body.string()
}
}
if (code in 200..299) {
// Real JSON decode — the ticket is base64url and may contain
// characters a naive [^"]+ regex cannot extract (see
// testGatedMode_parsesEscapedTicketFromRealJsonShape).
val parsed =
runCatching {
OkHttpProvider.json.decodeFromString<WsTicketResponse>(body)
}.getOrNull()
val ticket = parsed?.ticket
if (!ticket.isNullOrBlank()) {
return TicketRequestResult(ticket, null)
}
Log.w(TAG, "WS ticket mint failed: unparseable ticket response")
} else {
Log.w(TAG, "WS ticket mint failed: HTTP $code")
return TicketRequestResult(null, code)
}
} catch (e: Exception) {
// Include the stack: the exception class alone (e.g.
// NetworkOnMainThreadException) can't show WHICH caller on what
// thread tripped the mint (main-scope kanban events connect vs
// the WS reconnect path).
Log.w(TAG, "WS ticket mint failed: ${e.javaClass.simpleName}", e)
return TicketRequestResult(null, null, exception = true)
}
return TicketRequestResult(null, null)
}
private fun handleWsTicketRefreshFailure(
status: ConnectionStatus,
generation: Int,
): Boolean {
synchronized(outboundLock) {
if (!isCurrentGeneration(generation)) return false
_connectionStatus.value = status
if (status == ConnectionStatus.RECONNECTING) scheduleReconnect()
}
return false
}
fun disconnect(clearPendingMessages: Boolean = false) {
ActiveSessionHolder.clear()
synchronized(outboundLock) {
intentionalClose.set(true)
acceptQueuedMessages.set(!clearPendingMessages)
connectionGeneration.incrementAndGet()
reconnectJob?.cancel()
reconnectJob = null
outboundDrainJob?.cancel()
outboundDrainJob = null
connectionHealth.cancelForegroundProbe()
connectionHealth.stop()
webSocket?.close(1000, "Client closed")
webSocket = null
closingSocket = null
rpcChannel.clearCapabilityRequests()
if (clearPendingMessages) {
backgroundConnectionLease.set(false)
messageQueue.clear()
queuedMessagesById.clear()
pendingPromptSubmits.clear()
pendingReply = false
replayTracker.clear()
}
connected.set(false)
_connectionStatus.value = ConnectionStatus.DISCONNECTED
}
}
fun cancelSocket() {
synchronized(outboundLock) {
if (connected.get()) webSocket?.cancel()
}
}
fun sendDirectFrame(frameJson: String): Boolean =
synchronized(outboundLock) {
val ws = webSocket
if (ws == null || !connected.get()) false else ws.send(frameJson)
}
fun send(
request: JsonRpcRequest,
onSent: ((String) -> Unit)? = null,
queueIfDisconnected: Boolean = true,
): String {
val id = request.id
val method = request.method
val json = rpcChannel.encodeRequest(request)
if (BuildConfig.DEBUG) {
Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false))
}
var reconnect = false
synchronized(outboundLock) {
if (method == WsMethods.PROMPT_SUBMIT) {
pendingPromptSubmits.add(id)
pendingReply = true
}
val ws = webSocket
if (ws != null && connected.get()) {
// Never replay send(true): the server may have executed it even if its response is lost.
if (ws.send(json)) {
onSent?.invoke(id)
} else {
if (webSocket !== ws || !acceptQueuedMessages.get()) return@synchronized
if (queueIfDisconnected && isRetryableMessage(json)) {
onSent?.invoke(id)
Log.w(TAG, "WS rejected outgoing message — queuing for reconnect")
queueMessage(id, json)
recoverRejectedSocket(ws)
} else {
Log.w(TAG, "WS rejected oversized outgoing message — not retrying")
}
}
} else if (acceptQueuedMessages.get() && queueIfDisconnected) {
if (isRetryableMessage(json)) {
onSent?.invoke(id)
Log.d(TAG, "WS disconnected — queuing message")
queueMessage(id, json)
reconnect = true
} else {
Log.w(TAG, "WS disconnected with oversized outgoing message — not queueing")
}
}
}
if (reconnect) connect()
return id
}
fun removeQueuedMessage(id: String) {
synchronized(outboundLock) {
queuedMessagesById.remove(id)?.let(messageQueue::remove)
}
}
fun onRpcResult(id: String) {
synchronized(outboundLock) {
pendingPromptSubmits.remove(id)
}
}
fun onRpcError(id: String) {
synchronized(outboundLock) {
if (pendingPromptSubmits.remove(id) && pendingPromptSubmits.isEmpty()) {
pendingReply = false
disconnectIfIdleInBackground()
}
}
}
fun observeEvent(event: WsEvent) {
when (event) {
is WsEvent.MessageStart,
is WsEvent.MessageToken,
is WsEvent.ThinkingDelta,
is WsEvent.ReasoningDelta,
is WsEvent.ToolStart,
-> {
pendingReply = true
}
is WsEvent.MessageComplete -> {
pendingReply = false
disconnectIfIdleInBackground()
}
else -> {}
}
}
private fun isRetryableMessage(json: String): Boolean {
if (json.length > MAX_OUTBOUND_MESSAGE_BYTES) return false
if (json.length <= MAX_OUTBOUND_MESSAGE_BYTES / 4) return true
return json.utf8Size() <= MAX_OUTBOUND_MESSAGE_BYTES.toLong()
}
private fun queueMessage(
id: String,
json: String,
) {
if (queuedMessagesById.putIfAbsent(id, json) == null) messageQueue.add(json)
}
private fun markQueuedMessageSent(json: String) {
queuedMessagesById.entries.removeIf { it.value == json }
}
private fun recoverRejectedSocket(ws: WebSocket) {
connected.set(false)
if (closingSocket === ws) return
_connectionStatus.value = ConnectionStatus.RECONNECTING
if (ws.queueSize() == 0L) {
ws.cancel()
scheduleReconnect()
return
}
if (intentionalClose.get()) return
outboundDrainJob?.cancel()
outboundDrainJob =
scope.launch {
delay(OUTBOUND_DRAIN_TIMEOUT_MS)
synchronized(outboundLock) {
if (!connected.get() && !intentionalClose.get() && webSocket === ws) {
ws.cancel()
outboundDrainJob = null
scheduleReconnect()
}
}
}
}
fun sendMessage(
sessionId: String,
text: String,
onSent: ((String) -> Unit)? = null,
queued: Boolean = false,
): String =
send(
rpcChannel.createRequest(
method = RpcMethods.PROMPT_SUBMIT.name,
params =
PromptSubmitParams(
sessionId = sessionId,
text = text,
queued = queued.takeIf { it },
).let { rpcChannel.encodeParams(RpcMethods.PROMPT_SUBMIT, it) },
),
onSent = onSent,
)
fun sendRedirect(
sessionId: String,
text: String,
onSent: ((String) -> Unit)? = null,
): String =
send(
rpcChannel.createRequest(
method = RpcMethods.SESSION_REDIRECT.name,
params =
SessionCorrectionParams(
sessionId = sessionId,
text = text,
).let { rpcChannel.encodeParams(RpcMethods.SESSION_REDIRECT, it) },
),
onSent = onSent,
)
internal fun openSocket() {
val generation =
synchronized(outboundLock) {
if (intentionalClose.get()) return
connectionGeneration.incrementAndGet()
}
scope.launch(Dispatchers.IO) {
if (!refreshWsTicketIfNeeded(generation)) {
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
return@launch
}
if (!isCurrentGeneration(generation)) return@launch
val url = AuthManager.wsUrl()
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
val request = Request.Builder().url(url).build()
val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation))
synchronized(outboundLock) {
if (connectionGeneration.get() == generation && !intentionalClose.get()) {
webSocket = newSocket
} else {
newSocket.cancel()
}
}
}
}
private fun isCurrentGeneration(generation: Int): Boolean =
connectionGeneration.get() == generation && !intentionalClose.get()
private fun scheduleReconnect() {
synchronized(outboundLock) {
if (intentionalClose.get() ||
_connectionStatus.value == ConnectionStatus.AUTH_EXPIRED ||
reconnectJob?.isActive == true
) {
return
}
if (!AuthManager.isAutoReconnect()) {
if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled")
_connectionStatus.value = ConnectionStatus.DISCONNECTED
return
}
if (!NetworkMonitor.isConnected.value) {
Log.d(TAG, "No network available — delaying reconnect scheduling")
_connectionStatus.value = ConnectionStatus.NO_NETWORK
return
}
val reconnectDelay = reconnectPolicy.nextDelayMs()
reconnectPolicy.advance()
if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${reconnectDelay}ms …")
reconnectJob =
scope.launch {
delay(reconnectDelay)
val owner = currentCoroutineContext()[Job]
val shouldOpen =
synchronized(outboundLock) {
if (reconnectJob !== owner) {
false
} else {
reconnectJob = null
!intentionalClose.get() &&
!connected.get() &&
_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED
}
}
if (shouldOpen) openSocket()
}
}
}
private fun isTerminalAuthClose(
code: Int,
reason: String,
): Boolean =
code == 4401 || code == 4403 ||
reason.contains("unauthorized", ignoreCase = true) ||
reason.startsWith("auth:", ignoreCase = true)
private inner class WsListenerImpl(
private val generation: Int,
) : WebSocketListener() {
private fun isCurrent(): Boolean = isCurrentGeneration(generation)
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
synchronized(outboundLock) {
if (!isCurrent()) {
webSocket.close(1000, "Superseded")
return
}
this@WsTransport.webSocket = webSocket
closingSocket = null
outboundDrainJob?.cancel()
outboundDrainJob = null
Log.i(TAG, "WebSocket opened")
connected.set(true)
_connectionStatus.value = ConnectionStatus.CONNECTED
reconnectPolicy.reset()
connectionHealth.start()
while (true) {
val msg = messageQueue.peek() ?: break
if (!isRetryableMessage(msg)) {
Log.w(TAG, "Dropping oversized queued message")
messageQueue.poll()
markQueuedMessageSent(msg)
continue
}
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg))
if (!webSocket.send(msg)) {
recoverRejectedSocket(webSocket)
break
}
messageQueue.poll()
markQueuedMessageSent(msg)
}
replayTracker.triggerReplay()
}
disconnectIfIdleInBackground()
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
if (!isCurrent() || this@WsTransport.webSocket !== webSocket) return
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text))
connectionHealth.onInboundFrame()
onRawMessage(text)
}
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
synchronized(outboundLock) {
if (!isCurrent()) {
webSocket.close(code, reason)
return
}
closingSocket = webSocket
// Do NOT log [reason] — it may carry server-side context.
Log.d(TAG, "WebSocket closing: $code")
if (isTerminalAuthClose(code, reason)) {
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
}
webSocket.close(code, reason)
}
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
synchronized(outboundLock) {
if (!isCurrent()) return
connectionGeneration.incrementAndGet()
closingSocket = null
outboundDrainJob?.cancel()
outboundDrainJob = null
if (this@WsTransport.webSocket === webSocket) this@WsTransport.webSocket = null
// Do NOT log [reason] — it may carry server-side context. The
// reason is still inspected internally to detect auth failures.
Log.i(TAG, "WebSocket closed: $code")
connected.set(false)
ActiveSessionHolder.clear()
connectionHealth.stop()
rpcChannel.clearCapabilityRequests()
if (isTerminalAuthClose(code, reason)) {
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
_connectionStatus.value = ConnectionStatus.RECONNECTING
scheduleReconnect()
}
}
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
synchronized(outboundLock) {
if (!isCurrent()) return
connectionGeneration.incrementAndGet()
closingSocket = null
outboundDrainJob?.cancel()
outboundDrainJob = null
if (this@WsTransport.webSocket === webSocket) this@WsTransport.webSocket = null
// Log the exception class only — [Throwable.message] can leak URLs
// or headers. The message is still inspected internally for auth
// detection.
Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t)
connected.set(false)
ActiveSessionHolder.clear()
connectionHealth.stop()
rpcChannel.clearCapabilityRequests()
val code = response?.code ?: 0
if (code == 401 || code == 4401 || code == 4403 ||
t.message?.contains("401") == true ||
t.message?.contains("4401") == true ||
t.message?.contains("4403") == true ||
t.message?.contains("unauthorized", ignoreCase = true) == true
) {
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
_connectionStatus.value = ConnectionStatus.RECONNECTING
scheduleReconnect()
}
}
}
}
// ── Test seams (issue #1376 extraction support) ──────────────────────
@VisibleForTesting
internal val connectedForTest: AtomicBoolean
get() = connected
@VisibleForTesting
internal val intentionalCloseForTest: AtomicBoolean
get() = intentionalClose
@VisibleForTesting
internal val connectionGenerationForTest: AtomicInteger
get() = connectionGeneration
@VisibleForTesting
internal val messageQueueForTest: ConcurrentLinkedQueue<String>
get() = messageQueue
@VisibleForTesting
internal val outboundLockForTest: Any
get() = outboundLock
@VisibleForTesting
internal val acceptQueuedMessagesForTest: AtomicBoolean
get() = acceptQueuedMessages
@VisibleForTesting
internal val connectionStatusForTest: MutableStateFlow<ConnectionStatus>
get() = _connectionStatus
@VisibleForTesting
internal var webSocketForTest: WebSocket?
get() = webSocket
set(value) {
webSocket = value
}
@VisibleForTesting
internal var currentBackoffForTest: Long
get() = synchronized(outboundLock) { reconnectPolicy.currentBackoff }
set(value) {
synchronized(outboundLock) {
reconnectPolicy.currentBackoff = value
}
}
@VisibleForTesting
internal var reconnectJobForTest: Job?
get() = reconnectJob
set(value) {
reconnectJob = value
}
@VisibleForTesting
internal var outboundDrainJobForTest: Job?
get() = outboundDrainJob
set(value) {
outboundDrainJob = value
}
@VisibleForTesting
internal fun setReconnectBackoffForTest(initialMillis: Long) {
synchronized(outboundLock) {
reconnectPolicy.setBackoffForTest(initialMillis)
}
}
@VisibleForTesting
internal fun scheduleReconnectForTest() {
scheduleReconnect()
}
@VisibleForTesting
internal fun createListenerForTest(generation: Int): WebSocketListener = WsListenerImpl(generation)
@VisibleForTesting
internal fun setConnectionStatusForTest(status: ConnectionStatus) {
_connectionStatus.value = status
}
@VisibleForTesting
internal fun formatSafeOutgoingFrameLog(
id: String,
method: String,
paramsKeys: Collection<String>?,
byteLength: Int,
queued: Boolean = false,
): String {
val prefix = if (queued) "→ (queued)" else "→"
val keys = paramsKeys?.joinToString(",", prefix = "[", postfix = "]") ?: "[]"
return "$prefix id=$id method=$method paramsKeys=$keys bytes=$byteLength"
}
@VisibleForTesting
internal fun formatSafeQueuedFrameLog(msg: String): String =
try {
val req = OkHttpProvider.json.decodeFromString<JsonRpcRequest>(msg)
formatSafeOutgoingFrameLog(req.id, req.method, req.params.keys, msg.length, queued = true)
} catch (_: Throwable) {
"→ (queued) frame bytes=${msg.length}"
}
@VisibleForTesting
internal fun formatSafeIncomingFrameLog(text: String): String =
try {
val element = OkHttpProvider.json.parseToJsonElement(text)
if (element is JsonObject) {
val id = (element["id"] as? JsonPrimitive)?.content
val method = (element["method"] as? JsonPrimitive)?.content
val hasResult = element.containsKey("result")
val resultKeys =
(element["result"] as? JsonObject)?.keys?.joinToString(
",",
prefix = "[",
postfix = "]",
)
val errorObj = element["error"] as? JsonObject
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.content
val sb = StringBuilder("← ")
if (id != null) sb.append("id=").append(id).append(" ")
if (method != null) sb.append("method=").append(method).append(" ")
if (hasResult) sb.append("result=").append(resultKeys ?: "present").append(" ")
if (errorCode != null) sb.append("errorCode=").append(errorCode).append(" ")
sb.append("bytes=").append(text.length).toString()
} else {
"← non-object frame bytes=${text.length}"
}
} catch (_: Throwable) {
"← frame bytes=${text.length}"
}
}
@@ -0,0 +1,28 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ApprovalPendingParams(
@SerialName("session_id") val sessionId: String,
)
@Serializable
data class ApprovalReceivedParams(
@SerialName("session_id") val sessionId: String,
@SerialName("request_id") val requestId: String,
)
@Serializable
data class ApprovalRespondParams(
@SerialName("session_id") val sessionId: String,
@SerialName("choice") val choice: String? = null,
@SerialName("all") val all: Boolean? = null,
@SerialName("request_id") val requestId: String? = null,
)
@Serializable
data class SessionActiveListParams(
@SerialName("current_session_id") val currentSessionId: String? = null,
)
@@ -0,0 +1,40 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data object EmptyParams
@Serializable
data class SubscriptionPreviewParams(
@SerialName("subscription_type_id") val subscriptionTypeId: String? = null,
)
@Serializable
data class SubscriptionChangeParams(
@SerialName("subscription_type_id") val subscriptionTypeId: String? = null,
@SerialName("cancel") val cancel: Boolean? = null,
)
@Serializable
data class SubscriptionUpgradeParams(
@SerialName("subscription_type_id") val subscriptionTypeId: String? = null,
)
@Serializable
data class VaultSourceSetParams(
@SerialName("name") val name: String? = null,
@SerialName("enabled") val enabled: Boolean? = null,
)
@Serializable
data class VaultUnlockParams(
@SerialName("name") val name: String? = null,
@SerialName("password") val password: String? = null,
)
@Serializable
data class VaultLockParams(
@SerialName("name") val name: String? = null,
)
@@ -0,0 +1,40 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** `commands.catalog` is sent without explicit params; the active profile is injected by WsProfileParams. */
@Serializable
data object CommandsCatalogParams
/** `arg` is sent whenever non-null, including an empty string, exactly like the old map payload. */
@Serializable
data class CommandDispatchParams(
@SerialName("name") val name: String,
@SerialName("arg") val arg: String? = null,
@SerialName("session_id") val sessionId: String? = null,
)
@Serializable
data class SlashExecParams(
@SerialName("session_id") val sessionId: String,
@SerialName("command") val command: String,
)
/** `data_url` carries the bytes for an uploaded file; `path` is for gateway-visible files. */
@Serializable
data class FileAttachParams(
@SerialName("session_id") val sessionId: String,
@SerialName("path") val path: String? = null,
@SerialName("data_url") val dataUrl: String? = null,
@SerialName("name") val name: String? = null,
)
/** The mobile client always sends the payload as `content_base64`; the `data` alias is not modelled. */
@Serializable
data class ImageAttachBytesParams(
@SerialName("session_id") val sessionId: String,
@SerialName("content_base64") val contentBase64: String? = null,
@SerialName("filename") val filename: String? = null,
@SerialName("ext") val ext: String? = null,
)
@@ -0,0 +1,41 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* `config.set` params. [key] and [value] are always encoded (no defaults); the rest are omitted
* when null. `profile` is injected by WsProfileParams, so it is not a field here.
*/
@Serializable
data class ConfigSetParams(
@SerialName("key") val key: String,
@SerialName("value") val value: String,
@SerialName("session_id") val sessionId: String? = null,
@SerialName("scope") val scope: String? = null,
@SerialName("confirm_expensive_model") val confirmExpensiveModel: Boolean? = null,
)
@Serializable
data class ConfigGetParams(
@SerialName("key") val key: String,
@SerialName("session_id") val sessionId: String? = null,
@SerialName("cwd") val cwd: String? = null,
)
/**
* `model.options` params. Flags are `Boolean?` so an explicit `false` is still sent while an
* unset flag is omitted (the client Json has `encodeDefaults = false`).
*/
@Serializable
data class ModelOptionsParams(
@SerialName("session_id") val sessionId: String? = null,
@SerialName("explicit_only") val explicitOnly: Boolean? = null,
@SerialName("include_unconfigured") val includeUnconfigured: Boolean? = null,
@SerialName("refresh") val refresh: Boolean? = null,
)
@Serializable
data class ClientCapabilitiesParams(
@SerialName("server_requests") val serverRequests: Boolean,
)
@@ -0,0 +1,26 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ConnectionAnswerTarget(
@SerialName("name") val name: String,
@SerialName("status") val status: String,
@SerialName("detail") val detail: String? = null,
@SerialName("env") val env: Map<String, String>? = null,
)
@Serializable
data class ConnectionAnswer(
@SerialName("targets") val targets: List<ConnectionAnswerTarget>? = null,
@SerialName("settled_by") val settledBy: String? = null,
)
@Serializable
data class ConnectionRespondParams(
@SerialName("owner") val owner: ConnectorOwner,
@SerialName("op_id") val opId: String,
@SerialName("result") val result: ConnectionAnswer,
@SerialName("profile") val profile: String? = null,
)
@@ -0,0 +1,74 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class ConnectorOwner(
@SerialName("type") val type: String,
@SerialName("session_id") val sessionId: String? = null,
) {
companion object {
fun account() = ConnectorOwner("account")
fun session(sessionId: String) = ConnectorOwner("session", sessionId)
}
}
@Serializable
data class ConnectorsListParams(
@SerialName("owner") val owner: ConnectorOwner,
@SerialName("profile") val profile: String? = null,
)
@Serializable
data class ConnectorsConnectParams(
@SerialName("owner") val owner: ConnectorOwner,
@SerialName("connectors") val connectors: List<String>,
@SerialName("reconnect") val reconnect: Boolean? = null,
@SerialName("profile") val profile: String? = null,
)
@Serializable
data class ConnectorsOperationStatusParams(
@SerialName("owner") val owner: ConnectorOwner,
@SerialName("op_id") val opId: String,
@SerialName("profile") val profile: String? = null,
)
@Serializable
data class ConnectorsCatalogParams(
@SerialName("profile") val profile: String? = null,
)
@Serializable
data class ConnectorsAccountsParams(
@SerialName("connector") val connector: String? = null,
@SerialName("profile") val profile: String? = null,
)
@Serializable
data class ConnectorsAccountsRemoveParams(
@SerialName("connection_id") val connectionId: String,
@SerialName("profile") val profile: String? = null,
)
@Serializable
data class ConnectorsPolicyGetParams(
@SerialName("profile") val profile: String? = null,
)
@Serializable
data class ConnectorsPolicySetParams(
@SerialName("change") val change: JsonObject,
@SerialName("expected_revision") val expectedRevision: String,
@SerialName("profile") val profile: String? = null,
)
@Serializable
data class ConnectorsToolsParams(
@SerialName("slug") val slug: String,
@SerialName("refresh") val refresh: Boolean? = null,
@SerialName("profile") val profile: String? = null,
)
@@ -0,0 +1,25 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/**
* `plugins.manage` params. [action] has no default so it is always encoded; every other field is
* omitted when null. [profile] is an explicit override only; otherwise WsProfileParams injects it.
*/
@Serializable
data class PluginsManageParams(
@SerialName("action") val action: String,
@SerialName("profile") val profile: String? = null,
@SerialName("key") val key: String? = null,
@SerialName("name") val name: String? = null,
@SerialName("enable") val enable: Boolean? = null,
@SerialName("identifier") val identifier: String? = null,
@SerialName("repo") val repo: String? = null,
@SerialName("catalog_name") val catalogName: String? = null,
@SerialName("force") val force: Boolean? = null,
@SerialName("ref") val ref: String? = null,
@SerialName("accept_capabilities") val acceptCapabilities: Boolean? = null,
@SerialName("values") val values: JsonObject? = null,
)
@@ -0,0 +1,10 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ProcessKillParams(
@SerialName("session_id") val sessionId: String,
@SerialName("process_id") val processId: String,
)
@@ -0,0 +1,15 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.Serializable
/**
* `process.stop` kills every background process in the gateway registry (the desktop `/stop` second half). Desktop
* sends `{}`: the backend call is global, so no `session_id` or `profile` override is sent.
*/
@Serializable
data object ProcessStopParams
@Serializable
data class ProcessStopResult(
val killed: Int? = null,
)
@@ -0,0 +1,16 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/**
* `profiles.configure` params for the sections the app writes: `ui_meta` and the bot `soul`.
* `profile` is not a field: WsProfileParams injects it when the method is profile scoped.
*/
@Serializable
data class ProfilesConfigureParams(
@SerialName("name") val name: String? = null,
@SerialName("ui_meta") val uiMeta: JsonObject? = null,
@SerialName("soul") val soul: String? = null,
)
@@ -0,0 +1,7 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.Serializable
/** `profiles.list` takes no explicit params here; the active profile is injected by WsProfileParams. */
@Serializable
data object ProfilesListParams
@@ -0,0 +1,7 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.Serializable
/** `projects.list` takes no explicit params; the active profile is injected by WsProfileParams. */
@Serializable
data object ProjectsListParams
@@ -0,0 +1,539 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.JsonElement
class RpcMethod<P, R>(
val name: String,
val params: KSerializer<P>,
val result: KSerializer<R>,
)
interface TypedRpcSender {
fun <P> send(
method: RpcMethod<P, *>,
params: P,
onSent: ((String) -> Unit)? = null,
): String
}
interface TypedRpcCaller {
suspend fun <P, R> call(
method: RpcMethod<P, R>,
params: P,
): R
}
object HermesRpcCaller : TypedRpcCaller {
override suspend fun <P, R> call(
method: RpcMethod<P, R>,
params: P,
): R = HermesWsClient.call(method, params)
}
object RpcMethods {
val SESSION_EVENTS_SINCE: RpcMethod<SessionEventsSinceParams, SessionEventsSinceResult> =
RpcMethod(
WsMethods.SESSION_EVENTS_SINCE,
SessionEventsSinceParams.serializer(),
SessionEventsSinceResult.serializer(),
)
val SESSION_CREATE: RpcMethod<SessionCreateParams, SessionCreateResult> =
RpcMethod(
WsMethods.SESSION_CREATE,
SessionCreateParams.serializer(),
SessionCreateResult.serializer(),
)
val SESSION_RESUME: RpcMethod<SessionResumeParams, SessionResumeResult> =
RpcMethod(
WsMethods.SESSION_RESUME,
SessionResumeParams.serializer(),
SessionResumeResult.serializer(),
)
val PROMPT_SUBMIT: RpcMethod<PromptSubmitParams, PromptSubmitResult> =
RpcMethod(
WsMethods.PROMPT_SUBMIT,
PromptSubmitParams.serializer(),
PromptSubmitResult.serializer(),
)
val SESSION_INTERRUPT: RpcMethod<SessionInterruptParams, SessionInterruptResult> =
RpcMethod(
WsMethods.SESSION_INTERRUPT,
SessionInterruptParams.serializer(),
SessionInterruptResult.serializer(),
)
val SESSION_STEER: RpcMethod<SessionCorrectionParams, SessionCorrectionResult> =
RpcMethod(
WsMethods.SESSION_STEER,
SessionCorrectionParams.serializer(),
SessionCorrectionResult.serializer(),
)
val SESSION_REDIRECT: RpcMethod<SessionCorrectionParams, SessionCorrectionResult> =
RpcMethod(
WsMethods.SESSION_REDIRECT,
SessionCorrectionParams.serializer(),
SessionCorrectionResult.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val SESSION_LIST: RpcMethod<SessionListParams, JsonElement> =
RpcMethod(
WsMethods.SESSION_LIST,
SessionListParams.serializer(),
JsonElement.serializer(),
)
val SESSION_BRANCH: RpcMethod<SessionBranchParams, JsonElement> =
RpcMethod(
WsMethods.SESSION_BRANCH,
SessionBranchParams.serializer(),
JsonElement.serializer(),
)
val SESSION_BRANCH_WHOLE: RpcMethod<SessionBranchWholeParams, JsonElement> =
RpcMethod(
WsMethods.SESSION_BRANCH_WHOLE,
SessionBranchWholeParams.serializer(),
JsonElement.serializer(),
)
val SESSION_COMPRESS: RpcMethod<SessionCompressParams, JsonElement> =
RpcMethod(
WsMethods.SESSION_COMPRESS,
SessionCompressParams.serializer(),
JsonElement.serializer(),
)
val SESSION_CONTEXT_BREAKDOWN: RpcMethod<SessionIdParams, JsonElement> =
RpcMethod(
WsMethods.SESSION_CONTEXT_BREAKDOWN,
SessionIdParams.serializer(),
JsonElement.serializer(),
)
val SESSION_USAGE: RpcMethod<SessionIdParams, JsonElement> =
RpcMethod(
WsMethods.SESSION_USAGE,
SessionIdParams.serializer(),
JsonElement.serializer(),
)
val PROMPT_BTW: RpcMethod<PromptBtwParams, PromptBtwResult> =
RpcMethod(
WsMethods.PROMPT_BTW,
PromptBtwParams.serializer(),
PromptBtwResult.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val APPROVAL_PENDING: RpcMethod<ApprovalPendingParams, JsonElement> =
RpcMethod(
WsMethods.APPROVAL_PENDING,
ApprovalPendingParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val APPROVAL_RECEIVED: RpcMethod<ApprovalReceivedParams, JsonElement> =
RpcMethod(
WsMethods.APPROVAL_RECEIVED,
ApprovalReceivedParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val APPROVAL_RESPOND: RpcMethod<ApprovalRespondParams, JsonElement> =
RpcMethod(
WsMethods.APPROVAL_RESPOND,
ApprovalRespondParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val SESSION_ACTIVE_LIST: RpcMethod<SessionActiveListParams, JsonElement> =
RpcMethod(
WsMethods.SESSION_ACTIVE_LIST,
SessionActiveListParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val SUBSCRIPTION_STATE: RpcMethod<EmptyParams, JsonElement> =
RpcMethod(
WsMethods.SUBSCRIPTION_STATE,
EmptyParams.serializer(),
JsonElement.serializer(),
)
val SUBSCRIPTION_PREVIEW: RpcMethod<SubscriptionPreviewParams, JsonElement> =
RpcMethod(
WsMethods.SUBSCRIPTION_PREVIEW,
SubscriptionPreviewParams.serializer(),
JsonElement.serializer(),
)
val SUBSCRIPTION_CHANGE: RpcMethod<SubscriptionChangeParams, JsonElement> =
RpcMethod(
WsMethods.SUBSCRIPTION_CHANGE,
SubscriptionChangeParams.serializer(),
JsonElement.serializer(),
)
val SUBSCRIPTION_UPGRADE: RpcMethod<SubscriptionUpgradeParams, JsonElement> =
RpcMethod(
WsMethods.SUBSCRIPTION_UPGRADE,
SubscriptionUpgradeParams.serializer(),
JsonElement.serializer(),
)
val SUBSCRIPTION_RESUME: RpcMethod<EmptyParams, JsonElement> =
RpcMethod(
WsMethods.SUBSCRIPTION_RESUME,
EmptyParams.serializer(),
JsonElement.serializer(),
)
val USAGE_BARS: RpcMethod<EmptyParams, JsonElement> =
RpcMethod(
WsMethods.USAGE_BARS,
EmptyParams.serializer(),
JsonElement.serializer(),
)
val VAULT_LIST: RpcMethod<EmptyParams, JsonElement> =
RpcMethod(
WsMethods.VAULT_LIST,
EmptyParams.serializer(),
JsonElement.serializer(),
)
val VAULT_SOURCES: RpcMethod<EmptyParams, JsonElement> =
RpcMethod(
WsMethods.VAULT_SOURCES,
EmptyParams.serializer(),
JsonElement.serializer(),
)
val VAULT_SOURCE_SET: RpcMethod<VaultSourceSetParams, JsonElement> =
RpcMethod(
WsMethods.VAULT_SOURCE_SET,
VaultSourceSetParams.serializer(),
JsonElement.serializer(),
)
val VAULT_UNLOCK: RpcMethod<VaultUnlockParams, JsonElement> =
RpcMethod(
WsMethods.VAULT_UNLOCK,
VaultUnlockParams.serializer(),
JsonElement.serializer(),
)
val VAULT_LOCK: RpcMethod<VaultLockParams, JsonElement> =
RpcMethod(
WsMethods.VAULT_LOCK,
VaultLockParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_LIST: RpcMethod<ConnectorsListParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_LIST,
ConnectorsListParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_CONNECT: RpcMethod<ConnectorsConnectParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_CONNECT,
ConnectorsConnectParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_OPERATION_STATUS: RpcMethod<ConnectorsOperationStatusParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_OPERATION_STATUS,
ConnectorsOperationStatusParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_CATALOG: RpcMethod<ConnectorsCatalogParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_CATALOG,
ConnectorsCatalogParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_ACCOUNTS: RpcMethod<ConnectorsAccountsParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_ACCOUNTS,
ConnectorsAccountsParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_ACCOUNTS_REMOVE: RpcMethod<ConnectorsAccountsRemoveParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_ACCOUNTS_REMOVE,
ConnectorsAccountsRemoveParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_POLICY_GET: RpcMethod<ConnectorsPolicyGetParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_POLICY_GET,
ConnectorsPolicyGetParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_POLICY_SET: RpcMethod<ConnectorsPolicySetParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_POLICY_SET,
ConnectorsPolicySetParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_TOOLS: RpcMethod<ConnectorsToolsParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_TOOLS,
ConnectorsToolsParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTION_RESPOND: RpcMethod<ConnectionRespondParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTION_RESPOND,
ConnectionRespondParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy parsers.
val CONNECTORS_OPERATION_WAKE: RpcMethod<ConnectorsOperationStatusParams, JsonElement> =
RpcMethod(
WsMethods.CONNECTORS_OPERATION_WAKE,
ConnectorsOperationStatusParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val SUBAGENT_LIST: RpcMethod<SessionIdParams, JsonElement> =
RpcMethod(
WsMethods.SUBAGENT_LIST,
SessionIdParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val SUBAGENT_TAIL: RpcMethod<SubagentTailParams, JsonElement> =
RpcMethod(
WsMethods.SUBAGENT_TAIL,
SubagentTailParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val PROCESS_LIST: RpcMethod<SessionIdParams, JsonElement> =
RpcMethod(
WsMethods.PROCESS_LIST,
SessionIdParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val PROCESS_KILL: RpcMethod<ProcessKillParams, JsonElement> =
RpcMethod(
WsMethods.PROCESS_KILL,
ProcessKillParams.serializer(),
JsonElement.serializer(),
)
val PROCESS_STOP: RpcMethod<ProcessStopParams, ProcessStopResult> =
RpcMethod(
WsMethods.PROCESS_STOP,
ProcessStopParams.serializer(),
ProcessStopResult.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val PLUGINS_MANAGE: RpcMethod<PluginsManageParams, JsonElement> =
RpcMethod(
WsMethods.PLUGINS_MANAGE,
PluginsManageParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val PROJECTS_LIST: RpcMethod<ProjectsListParams, JsonElement> =
RpcMethod(
WsMethods.PROJECTS_LIST,
ProjectsListParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val PROFILES_LIST: RpcMethod<ProfilesListParams, JsonElement> =
RpcMethod(
WsMethods.PROFILES_LIST,
ProfilesListParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val PROFILES_CONFIGURE: RpcMethod<ProfilesConfigureParams, JsonElement> =
RpcMethod(
WsMethods.PROFILES_CONFIGURE,
ProfilesConfigureParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val CONFIG_SET: RpcMethod<ConfigSetParams, JsonElement> =
RpcMethod(
WsMethods.CONFIG_SET,
ConfigSetParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val CONFIG_GET: RpcMethod<ConfigGetParams, JsonElement> =
RpcMethod(
WsMethods.CONFIG_GET,
ConfigGetParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val MODEL_OPTIONS: RpcMethod<ModelOptionsParams, JsonElement> =
RpcMethod(
WsMethods.MODEL_OPTIONS,
ModelOptionsParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val CLIENT_CAPABILITIES: RpcMethod<ClientCapabilitiesParams, JsonElement> =
RpcMethod(
WsMethods.CLIENT_CAPABILITIES,
ClientCapabilitiesParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val COMMANDS_CATALOG: RpcMethod<CommandsCatalogParams, JsonElement> =
RpcMethod(
WsMethods.COMMANDS_CATALOG,
CommandsCatalogParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val COMMAND_DISPATCH: RpcMethod<CommandDispatchParams, JsonElement> =
RpcMethod(
WsMethods.COMMAND_DISPATCH,
CommandDispatchParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val SLASH_EXEC: RpcMethod<SlashExecParams, JsonElement> =
RpcMethod(
WsMethods.SLASH_EXEC,
SlashExecParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val FILE_ATTACH: RpcMethod<FileAttachParams, JsonElement> =
RpcMethod(
WsMethods.FILE_ATTACH,
FileAttachParams.serializer(),
JsonElement.serializer(),
)
// Passthrough JsonElement results: callers keep their existing legacy decoders.
val IMAGE_ATTACH_BYTES: RpcMethod<ImageAttachBytesParams, JsonElement> =
RpcMethod(
WsMethods.IMAGE_ATTACH_BYTES,
ImageAttachBytesParams.serializer(),
JsonElement.serializer(),
)
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */
val all: List<RpcMethod<*, *>> =
listOf(
SESSION_EVENTS_SINCE,
SESSION_CREATE,
SESSION_RESUME,
PROMPT_SUBMIT,
SESSION_INTERRUPT,
SESSION_STEER,
SESSION_REDIRECT,
SESSION_LIST,
SESSION_BRANCH,
SESSION_BRANCH_WHOLE,
SESSION_COMPRESS,
SESSION_CONTEXT_BREAKDOWN,
SESSION_USAGE,
PROMPT_BTW,
APPROVAL_PENDING,
APPROVAL_RECEIVED,
APPROVAL_RESPOND,
SESSION_ACTIVE_LIST,
SUBSCRIPTION_STATE,
SUBSCRIPTION_PREVIEW,
SUBSCRIPTION_CHANGE,
SUBSCRIPTION_UPGRADE,
SUBSCRIPTION_RESUME,
USAGE_BARS,
VAULT_LIST,
VAULT_SOURCES,
VAULT_SOURCE_SET,
VAULT_UNLOCK,
VAULT_LOCK,
CONNECTORS_LIST,
CONNECTORS_CONNECT,
CONNECTORS_OPERATION_STATUS,
CONNECTORS_CATALOG,
CONNECTORS_ACCOUNTS,
CONNECTORS_ACCOUNTS_REMOVE,
CONNECTORS_POLICY_GET,
CONNECTORS_POLICY_SET,
CONNECTORS_TOOLS,
CONNECTION_RESPOND,
CONNECTORS_OPERATION_WAKE,
SUBAGENT_LIST,
SUBAGENT_TAIL,
PROCESS_LIST,
PROCESS_KILL,
PROCESS_STOP,
PLUGINS_MANAGE,
PROJECTS_LIST,
PROFILES_LIST,
PROFILES_CONFIGURE,
CONFIG_SET,
CONFIG_GET,
MODEL_OPTIONS,
CLIENT_CAPABILITIES,
COMMANDS_CATALOG,
COMMAND_DISPATCH,
SLASH_EXEC,
FILE_ATTACH,
IMAGE_ATTACH_BYTES,
)
}
@@ -0,0 +1,26 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SessionInterruptParams(
@SerialName("session_id") val sessionId: String,
)
@Serializable
data class SessionCorrectionParams(
@SerialName("session_id") val sessionId: String,
@SerialName("text") val text: String,
)
@Serializable
data class SessionInterruptResult(
val status: String? = null,
)
@Serializable
data class SessionCorrectionResult(
val status: String? = null,
val text: String? = null,
)
@@ -0,0 +1,20 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class SessionEventsSinceParams(
@SerialName("session_id") val sessionId: String,
@SerialName("last_seen") val lastSeen: Int? = null,
) // NO profile field: profile is injected by WsProfileParams only.
// Tolerant reader: every field nullable/defaulted, mirrors what the old untyped parser tolerated.
@Serializable
data class SessionEventsSinceResult(
val events: List<JsonElement>? = null,
@SerialName("latest_seq") val latestSeq: Int? = null,
val truncated: Boolean? = null,
val epoch: String? = null,
)
@@ -0,0 +1,41 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data object SessionListParams
@Serializable
data class SessionIdParams(
@SerialName("session_id") val sessionId: String,
)
@Serializable
data class SessionBranchParams(
@SerialName("session_id") val sessionId: String,
@SerialName("name") val name: String? = null,
)
@Serializable
data class SessionBranchWholeParams(
@SerialName("session_id") val sessionId: String,
@SerialName("name") val name: String? = null,
)
@Serializable
data class SessionCompressParams(
@SerialName("session_id") val sessionId: String,
@SerialName("focus_topic") val focusTopic: String? = null,
)
@Serializable
data class PromptBtwParams(
@SerialName("session_id") val sessionId: String,
@SerialName("text") val text: String,
)
@Serializable
data class PromptBtwResult(
@SerialName("task_id") val taskId: String? = null,
)
@@ -0,0 +1,43 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SessionCreateParams(
@SerialName("source") val source: String? = null,
@SerialName("profile") val profile: String? = null,
@SerialName("title") val title: String? = null,
@SerialName("hidden") val hidden: Boolean? = null,
)
@Serializable
data class SessionResumeParams(
@SerialName("session_id") val sessionId: String,
@SerialName("omit_messages") val omitMessages: Boolean? = null,
@SerialName("profile") val profile: String? = null,
)
@Serializable
data class PromptSubmitParams(
@SerialName("session_id") val sessionId: String,
@SerialName("text") val text: String,
@SerialName("queued") val queued: Boolean? = null,
)
// tolerant readers: all nullable with null defaults, only fields callers read
@Serializable
data class SessionCreateResult(
@SerialName("session_id") val sessionId: String? = null,
@SerialName("stored_session_id") val storedSessionId: String? = null,
)
@Serializable
data class SessionResumeResult(
@SerialName("session_id") val sessionId: String? = null,
)
@Serializable
data class PromptSubmitResult(
val status: String? = null,
)
@@ -0,0 +1,10 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SubagentTailParams(
@SerialName("session_id") val sessionId: String,
@SerialName("subagent_id") val subagentId: String,
)
@@ -51,6 +51,7 @@ class BackgroundConnectionController(
status = status,
isAutoReconnect = AuthManager.isAutoReconnect(),
hasActiveNetwork = NetworkMonitor.isConnected.value,
notifyCompletionsOptIn = AuthManager.isNotifySessionCompletions(),
)
}
}
@@ -98,6 +99,17 @@ class BackgroundConnectionController(
}
}
/** The persistent opt-in was turned off while backgrounded (e.g. from the notification action). */
fun onKeepConnectedDisabled() {
val snapshot = snapshotProvider(false)
if (!snapshot.appInForeground) {
releaseLease()
}
// Force a re-post if the service stays up (pending reply) so the action button disappears.
currentNotificationState = BackgroundNotificationState.None
reconcileState()
}
fun reconcileState() {
val snapshot = snapshotProvider(false)
val decision = BackgroundConnectionPolicy.evaluate(snapshot)
@@ -14,6 +14,11 @@ data class BackgroundConnectionSnapshot(
val status: ConnectionStatus,
val isAutoReconnect: Boolean = true,
val hasActiveNetwork: Boolean = true,
/**
* User opted into "notify when a session completes" — completions arrive
* only over a live connection, so this implies the keep-connected lease.
*/
val notifyCompletionsOptIn: Boolean = false,
) {
companion object {
fun ineligible(
@@ -77,8 +82,12 @@ object BackgroundConnectionPolicy {
)
}
// Demand check: need either a pending reply or persistent opt-in
val hasDemand = snapshot.pendingReply || snapshot.keepConnectedOptIn
// Demand check: need a pending reply or a persistent opt-in. The
// completions notify opt-in implies the keep-connected lease — a
// session can only be observed finishing over a live connection.
val notifyCompletionsDemand = snapshot.notifyCompletionsOptIn
val keepConnectedLease = snapshot.keepConnectedOptIn || notifyCompletionsDemand
val hasDemand = snapshot.pendingReply || keepConnectedLease
if (!hasDemand) {
return BackgroundConnectionDecision(
shouldHoldService = false,
@@ -134,7 +143,7 @@ object BackgroundConnectionPolicy {
return BackgroundConnectionDecision(
shouldHoldService = true,
shouldHoldPersistentLease = snapshot.keepConnectedOptIn,
shouldHoldPersistentLease = keepConnectedLease,
notificationState = notificationState,
)
}
@@ -9,15 +9,21 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput
import com.m57.hermescontrol.MainActivity
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ActiveSessionsResponse
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionActiveListParams
import com.m57.hermescontrol.ui.chat.replyFailureFromPayload
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -26,6 +32,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.serializer
import java.util.concurrent.atomic.AtomicBoolean
internal data class MessageCompleteNotificationPlan(
@@ -37,6 +46,87 @@ internal data class MessageCompleteNotificationPlan(
val allowInlineReply: Boolean,
)
internal data class CompletedSessionRef(
val storedId: String,
val title: String?,
)
private const val TAG = "ChatNotificationService"
/** Short cap so a slow `session.active_list` cannot hold a completion alert hostage. */
private const val ACTIVE_LIST_TIMEOUT_MS = 5_000L
/** Where a background `message.complete` goes. */
internal sealed interface MessageCompleteRoute {
/** The reply flow: reply notification, then the foreground service is retired. */
data object Reply : MessageCompleteRoute
/** A session the phone did not submit finished; the opt-in alert fires. */
data object ForeignSession : MessageCompleteRoute
}
/**
* Route a completion the way the service did before the opt-in existed:
* toggle OFF must keep the legacy behavior exactly — every completion uses
* the reply path, even when `ActiveSessionHolder` was cleared by a background
* reconnect (dropping the completion would both hide the reply notification
* and strand the foreground service).
*/
internal fun messageCompleteRoute(
holderSessionId: String?,
toggleOn: Boolean,
eventSessionId: String?,
): MessageCompleteRoute =
if (!toggleOn || (!eventSessionId.isNullOrBlank() && eventSessionId == holderSessionId)) {
MessageCompleteRoute.Reply
} else {
MessageCompleteRoute.ForeignSession
}
/**
* Map a WS event's runtime session id to the STORED session row the app can
* open. The gateway emits `message.complete` with its ephemeral transport id
* (`uuid4().hex[:8]`), while History/`switchSession` use the persisted
* `session_key` — a runtime id taps into a blank chat. `session.active_list`
* lists every live session (idle included) with both ids plus the title.
*
* Accepts the typed RPC result (`JsonElement`) and its pre-JSON `Map` shape,
* mirroring `SessionLiveStatusDecoder.decodeResponse`.
*/
internal fun parseActiveSessionLookup(
raw: Any?,
runtimeId: String,
): CompletedSessionRef? {
if (runtimeId.isBlank()) return null
val element =
when (raw) {
is JsonObject -> {
raw
}
is String -> {
runCatching { lookupJson.parseToJsonElement(raw) }.getOrNull() ?: return null
}
else -> {
return null
}
}
val response =
runCatching {
lookupJson.decodeFromJsonElement(serializer<ActiveSessionsResponse>(), element)
}.getOrNull() ?: return null
for (item in response.sessions) {
if (item.id?.trim() != runtimeId) continue
val storedId = item.sessionKey?.trim().orEmpty()
if (storedId.isBlank()) return null
return CompletedSessionRef(storedId, item.title?.trim()?.takeIf { it.isNotEmpty() })
}
return null
}
private val lookupJson = Json { ignoreUnknownKeys = true }
internal fun messageCompleteNotificationPlan(
event: WsEvent.MessageComplete,
targetSessionId: String?,
@@ -106,6 +196,9 @@ class ChatNotificationService : Service() {
internal const val NOTIFICATION_ID = 1
internal const val PENDING_NOTIFICATION_ID = 2
/** Session-complete notifications: one fixed id, tagged per stored session. */
private const val SESSION_COMPLETE_NOTIFICATION_ID = 10_000
private val isAppInForeground = AtomicBoolean(false)
internal val lifecycle = ForegroundServiceLifecycle()
@@ -152,43 +245,99 @@ class ChatNotificationService : Service() {
if (!isAppInForeground.get()) {
when (event) {
is WsEvent.MessageComplete -> {
val targetSessionId =
event.storedSessionId
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
val plan =
messageCompleteNotificationPlan(
event = event,
targetSessionId = targetSessionId,
newMessageText = getString(R.string.notif_new_message),
failureText = getString(R.string.chat_reply_failed_title),
)
showReplyNotification(
text = plan.text,
sessionId = plan.sessionId,
isReplyMessage = plan.isReplyMessage,
completionId = plan.completionId,
allowInlineReply = plan.allowInlineReply,
// The durable REST row for this turn, when the
// boundary armed before the prompt was submitted
// still lets us name it unambiguously. Null is a
// normal, safe outcome: the notification is then
// never auto-dismissed from REST hydration, which
// is strictly better than dismissing the wrong
// duplicate reply.
serverMessageId =
plan.correlationText?.let {
coalesceTurnRow(plan.sessionId, it)
},
)
// The wait is over — retire the foreground
// service. The reply notification above
// replaces the persistent "waiting" one,
// and the pendingReply flag is cleared by
// HermesWsClient's own collector, so the
// service is not restarted on the next
// ON_STOP (issue #794).
// A delayed completion must not retire a newer turn/start.
BackgroundConnectionController.default.onReplyCompleted(generation)
when (
val route =
messageCompleteRoute(
holderSessionId = ActiveSessionHolder.activeSessionId.value,
toggleOn = AuthManager.isNotifySessionCompletions(),
eventSessionId = event.sessionId,
)
) {
MessageCompleteRoute.Reply -> {
val targetSessionId =
event.storedSessionId
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
val plan =
messageCompleteNotificationPlan(
event = event,
targetSessionId = targetSessionId,
newMessageText = getString(R.string.notif_new_message),
failureText = getString(R.string.chat_reply_failed_title),
)
showReplyNotification(
text = plan.text,
sessionId = plan.sessionId,
isReplyMessage = plan.isReplyMessage,
completionId = plan.completionId,
allowInlineReply = plan.allowInlineReply,
// The durable REST row for this turn, when the
// boundary armed before the prompt was submitted
// still lets us name it unambiguously. Null is a
// normal, safe outcome: the notification is then
// never auto-dismissed from REST hydration.
serverMessageId =
plan.correlationText?.let {
coalesceTurnRow(plan.sessionId, it)
},
)
// The wait is over: retire the foreground service
// (issue #794). A delayed completion must not
// retire a newer turn/start.
BackgroundConnectionController.default.onReplyCompleted(generation)
}
MessageCompleteRoute.ForeignSession -> {
// Another session finished. Resolve its runtime
// id to the STORED session row (the gateway's
// transport id means nothing to History/chat);
// without the stored id the tap would open a
// blank chat, so skip instead. The notification
// is plain — no inline reply, no reply-tracker
// registration — and the service keeps listening
// for the next completion (the opt-in demand
// holds it).
val ref =
try {
parseActiveSessionLookup(
HermesWsClient.call(
RpcMethods.SESSION_ACTIVE_LIST,
SessionActiveListParams(),
timeoutMs = ACTIVE_LIST_TIMEOUT_MS,
),
event.sessionId.orEmpty(),
)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
if (ref != null) {
// A failed turn must not read as success.
val failure =
replyFailureFromPayload(
event.rawPayload,
fallback = "Unknown gateway error",
)
val snippet =
(failure?.details ?: event.text)
.take(100)
.replace("\n", " ")
.ifBlank { getString(R.string.notif_new_message) }
val body =
if (failure != null) {
getString(R.string.notif_session_failed, snippet)
} else {
getString(R.string.notif_session_complete, snippet)
}
showSessionCompleteNotification(ref, body)
} else {
Log.w(
TAG,
"Session-complete notification skipped: no stored id for runtime ${event.sessionId}",
)
}
}
}
}
is WsEvent.ClarifyRequest -> {
@@ -364,6 +513,35 @@ class ChatNotificationService : Service() {
}
}
/**
* A completion from a session the phone did not submit (cron, another
* client). Fires only when "notify on session completion" is on, with the
* session's STORED id (tap opens the real transcript) and its title when
* the gateway reported one. Plain alert, auto-cancel; the notification
* uses the tag overload — per-session tag with one shared id — so tags
* never collide and no session's alert can clobber a live reply
* notification or tombstone the tracker.
*/
private fun showSessionCompleteNotification(
ref: CompletedSessionRef,
body: String,
) {
val notification =
NotificationCompat
.Builder(this, CHAT_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(ref.title ?: getString(R.string.notif_title))
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setAutoCancel(true)
.setContentIntent(buildContentIntent(ref.storedId))
.build()
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
manager.notify(ref.storedId, SESSION_COMPLETE_NOTIFICATION_ID, notification)
}
private fun buildContentIntent(sessionId: String?): PendingIntent {
val intent =
Intent(this, MainActivity::class.java).apply {
@@ -383,15 +561,48 @@ class ChatNotificationService : Service() {
)
}
private fun buildForegroundNotification(text: String): Notification =
NotificationCompat
.Builder(this, SERVICE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(getString(R.string.notif_title))
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setOngoing(true)
.build()
private fun buildForegroundNotification(text: String): Notification {
val openIntent =
Intent(this, MainActivity::class.java).apply {
setPackage(packageName)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val builder =
NotificationCompat
.Builder(this, SERVICE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(getString(R.string.notif_title))
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setOngoing(true)
.setContentIntent(
PendingIntent.getActivity(
this,
0,
openIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
)
if (AuthManager.isKeepConnectedInBackground()) {
val stopIntent =
Intent(this, StopKeepConnectedReceiver::class.java).apply {
action = StopKeepConnectedReceiver.ACTION_STOP_KEEP_CONNECTED
setPackage(packageName)
}
builder.addAction(
R.drawable.ic_notification,
getString(R.string.notif_action_turn_off),
PendingIntent.getBroadcast(
this,
0,
stopIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
)
}
return builder.build()
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -11,7 +11,9 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -25,6 +27,9 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
const val EXTRA_SESSION_ID = "extra_session_id"
private const val REPLY_TIMEOUT_MS = 5_000L
/** Overall deadline for persisting and submitting one notification reply. */
private const val PERSISTENCE_TIMEOUT_MS = 5_000L
/**
* Turn-boundary read budget inside the reply deadline. Deliberately tight:
* the probe sits on the reply path, so a slow or unreachable gateway must
@@ -44,6 +49,9 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
*/
internal open fun goAsyncCompat(): BroadcastReceiver.PendingResult = goAsync()
/** Overridable so tests can prove the timeout path without waiting 5s. */
internal open val persistenceTimeoutMs: Long get() = PERSISTENCE_TIMEOUT_MS
/**
* Test-friendly wrapper for notification creation. Override in tests to
* avoid [NotificationCompat.Builder.build()] calling Android framework
@@ -75,7 +83,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
val pendingResult = goAsyncCompat()
replyScope.launch {
try {
withTimeout(5000L) {
withTimeout(persistenceTimeoutMs) {
withContext(Dispatchers.IO) {
val db =
com.m57.hermescontrol.data.local.HermesDatabase
@@ -103,12 +111,11 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
val runtimeSessionId =
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
?: resumeSession(sessionId)
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeSessionId, "text" to replyText),
timeoutMs = REPLY_TIMEOUT_MS,
).await()
HermesWsClient.call(
method = RpcMethods.PROMPT_SUBMIT,
params = PromptSubmitParams(sessionId = runtimeSessionId, text = replyText),
timeoutMs = REPLY_TIMEOUT_MS,
)
val entity =
com.m57.hermescontrol.data.local.ChatMessageEntity(
@@ -146,24 +153,21 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
}
private suspend fun resumeSession(storedSessionId: String): String {
val profile = AuthManager.activeProfileId.value
val profile = AuthManager.activeProfileId.value?.takeIf { it.isNotBlank() }
val params =
mutableMapOf<String, Any>(
"session_id" to storedSessionId,
"omit_messages" to true,
SessionResumeParams(
sessionId = storedSessionId,
omitMessages = true,
profile = profile,
)
if (!profile.isNullOrBlank()) {
params["profile"] = profile
}
val result =
HermesWsClient
.request(
WsMethods.SESSION_RESUME,
params,
timeoutMs = REPLY_TIMEOUT_MS,
).await() as? Map<*, *>
HermesWsClient.call(
method = RpcMethods.SESSION_RESUME,
params = params,
timeoutMs = REPLY_TIMEOUT_MS,
)
val runtimeSessionId =
(result?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
result.sessionId?.takeIf { it.isNotBlank() }
?: error("Resume returned no runtime session id")
ActiveSessionHolder.set(runtimeSessionId, storedSessionId)
return runtimeSessionId
@@ -0,0 +1,26 @@
package com.m57.hermescontrol.notification
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.m57.hermescontrol.data.local.AuthManager
/**
* Handles the "Turn off" action on the ongoing background notification:
* disables "Stay connected in background" and lets the controller retire the
* lease/service.
*/
class StopKeepConnectedReceiver : BroadcastReceiver() {
companion object {
const val ACTION_STOP_KEEP_CONNECTED = "com.m57.hermescontrol.ACTION_STOP_KEEP_CONNECTED"
}
override fun onReceive(
context: Context,
intent: Intent,
) {
if (intent.action != ACTION_STOP_KEEP_CONNECTED) return
AuthManager.setKeepConnectedInBackground(false)
BackgroundConnectionController.default.onKeepConnectedDisabled()
}
}
@@ -91,3 +91,6 @@ fun parseProjectColor(raw: String?): Color? {
if (h !in 0f..360f || s !in 0f..1f || l !in 0f..1f) return null
return Color.hsl(h % 360f, s, l)
}
/** Pure white glyph for filled accent controls (composer action button) when it is legible on the preset primary. */
val GlyphWhite = Color(0xFFFFFFFF)
@@ -10,8 +10,13 @@ import androidx.compose.ui.graphics.luminance
* Material You changes the *primary* / *surface* palette based on the user's
* wallpaper, but success / warning / error / info colours must stay
* semantically correct (green = good, red = bad) regardless of what the
* wallpaper-derived palette would choose. This struct is provided via
* [LocalHermesStatusColors] and should be used for all status indicators,
* wallpaper-derived palette would choose.
*
* `onSuccess`/`onWarning`/`onError`/`onInfo` are foregrounds on the matching
* status fills, not on their container colors. Reusable status badges use
* these fill/on pairs; error-container content uses `onErrorContainer`.
*
* Use [LocalHermesStatusColors] for all status indicators,
* badges, and semantic feedback colours.
*/
data class HermesStatusColors(
@@ -28,6 +28,9 @@ enum class ThemeMode { FULL, DARK_ONLY, LIGHT_ONLY }
*
* The Material error slots are derived from [status] — the theme author
* defines semantic colors once and the scheme inherits them.
* [primary] and the status error are also used directly for text on surfaces.
* [outline] must contrast with [surface] and [surfaceContainerHighest], which
* Material uses as the unchecked switch track.
*/
data class PaletteColors(
val primary: Color,
@@ -48,6 +51,20 @@ data class PaletteColors(
val onSurface: Color,
val surfaceVariant: Color,
val onSurfaceVariant: Color,
val surfaceDim: Color,
val surfaceBright: Color,
val primaryFixed: Color,
val primaryFixedDim: Color,
val onPrimaryFixed: Color,
val onPrimaryFixedVariant: Color,
val secondaryFixed: Color,
val secondaryFixedDim: Color,
val onSecondaryFixed: Color,
val onSecondaryFixedVariant: Color,
val tertiaryFixed: Color,
val tertiaryFixedDim: Color,
val onTertiaryFixed: Color,
val onTertiaryFixedVariant: Color,
val surfaceContainerLowest: Color,
val surfaceContainerLow: Color,
val surfaceContainer: Color,
@@ -123,6 +140,20 @@ private fun darkSchemeOf(c: PaletteColors): ColorScheme =
surfaceVariant = c.surfaceVariant,
onSurfaceVariant = c.onSurfaceVariant,
surfaceTint = c.primary,
surfaceDim = c.surfaceDim,
surfaceBright = c.surfaceBright,
primaryFixed = c.primaryFixed,
primaryFixedDim = c.primaryFixedDim,
onPrimaryFixed = c.onPrimaryFixed,
onPrimaryFixedVariant = c.onPrimaryFixedVariant,
secondaryFixed = c.secondaryFixed,
secondaryFixedDim = c.secondaryFixedDim,
onSecondaryFixed = c.onSecondaryFixed,
onSecondaryFixedVariant = c.onSecondaryFixedVariant,
tertiaryFixed = c.tertiaryFixed,
tertiaryFixedDim = c.tertiaryFixedDim,
onTertiaryFixed = c.onTertiaryFixed,
onTertiaryFixedVariant = c.onTertiaryFixedVariant,
surfaceContainerLowest = c.surfaceContainerLowest,
surfaceContainerLow = c.surfaceContainerLow,
surfaceContainer = c.surfaceContainer,
@@ -161,6 +192,20 @@ private fun lightSchemeOf(c: PaletteColors): ColorScheme =
surfaceVariant = c.surfaceVariant,
onSurfaceVariant = c.onSurfaceVariant,
surfaceTint = c.primary,
surfaceDim = c.surfaceDim,
surfaceBright = c.surfaceBright,
primaryFixed = c.primaryFixed,
primaryFixedDim = c.primaryFixedDim,
onPrimaryFixed = c.onPrimaryFixed,
onPrimaryFixedVariant = c.onPrimaryFixedVariant,
secondaryFixed = c.secondaryFixed,
secondaryFixedDim = c.secondaryFixedDim,
onSecondaryFixed = c.onSecondaryFixed,
onSecondaryFixedVariant = c.onSecondaryFixedVariant,
tertiaryFixed = c.tertiaryFixed,
tertiaryFixedDim = c.tertiaryFixedDim,
onTertiaryFixed = c.onTertiaryFixed,
onTertiaryFixedVariant = c.onTertiaryFixedVariant,
surfaceContainerLowest = c.surfaceContainerLowest,
surfaceContainerLow = c.surfaceContainerLow,
surfaceContainer = c.surfaceContainer,
@@ -12,7 +12,8 @@ All theme code is under:
```
app/src/main/java/com/m57/hermescontrol/theme/
├── Theme.kt # dispatcher — preset lookup + mode fallback
├── Theme.kt # dispatcher — mode fallback + dynamic colors
├── ThemeRegistry.kt # single preset/palette registry
├── PaletteTemplate.kt # the template — the ONE shape every theme follows
├── Color.kt # shared tokens for non-theme code (status fallbacks, code blocks, …)
├── HermesStatusColors.kt # semantic status color model (success/warning/error/info + on*)
@@ -24,7 +25,8 @@ app/src/main/java/com/m57/hermescontrol/theme/
├── GruvboxScheme.kt
├── CatppuccinScheme.kt
├── AmoledScheme.kt # dark-only (ThemeMode.DARK_ONLY)
└── NordScheme.kt
├── NordScheme.kt
└── GarnetScheme.kt
```
## The template — one shape for every theme
@@ -55,7 +57,14 @@ val MyTheme =
The template maps `PaletteColors` into the Material 3 `ColorScheme` slots. The
Material `error` slots (`error`, `onError`, `errorContainer`, `onErrorContainer`)
are **derived** from the theme's status set — the theme author defines semantic
colors once.
colors once. `surfaceTint` comes from `primary`. Every other Material color role
is required explicitly, including bright/dim surfaces and all fixed accent roles.
No role may inherit Material baseline colors. Fixed accent and foreground roles
stay identical across a preset’s shipped modes; Fixed/Dim may share an official
swatch when the source palette has no accessible tonal ladder.
Prefer upstream swatches. Document every derived color beside its declaration
with the official pairing’s measured contrast and why a derived role is needed.
## Preset conventions
@@ -69,8 +78,41 @@ colors once.
light text would fail contrast.
- **Grayscale status colors** (Monochrome, AMOLED) separate success/warning/
error/info by lightness only — consumers must pair with icons or labels.
- Every shipped mode's error slot pairs are enforced at **>= 3:1 contrast** by
`ThemePaletteTest` — a preset that breaks this fails the unit-test gate.
- Normal-text foreground/background pairs, including accent containers, fixed
accents, semantic status fills, errors, and the surface ladder, are enforced at **>= 4.5:1 contrast**
by `ThemePaletteTest`. The separate full-bleed header guard remains >= 3:1.
Reusable `StatusBadge` and `SessionLiveStatusIndicator` use a shared
`statusBadgeColors` mapping: status fills (`success`, `warning`, `error`, `info`)
with their matching `on*` text/icons. `onSuccess` is not a foreground token for
`successContainer`. Palette tests exercise this exact renderer mapping in every
resolved mode, including fallback modes; neutral badges use Material tokens.
Enabled Material components also have a component-state contract:
- Graphics/boundaries target >= 3:1: switch thumbs/tracks and borders, radio and
checkbox states, slider active/inactive tracks, text-field borders, and segmented borders.
- Primary/error text and field labels on surface/background target >= 4.5:1.
- Segmented content uses the corresponding container foreground at >= 4.5:1.
`ThemeComponentContrastTest` checks the pinned Material role combinations in every
resolved preset/mode. `ThemeComponentGalleryTest` reads the installed Compose
public color defaults and verifies rendered pixels for all fourteen preset/mode
combinations, including focused/error fields. Disabled controls are deliberately
excluded from the enabled contrast contract; Material uses opacity to mute them.
Settings switches use Material defaults (`primary` track / `onPrimary` thumb),
not the reversed `primaryContainer` track / `primary` thumb combination.
Outlines must contrast against both surface and the unchecked switch track
(`surfaceContainerHighest`). Named palettes may need documented derived primary
or error tones for direct text; fixed roles still preserve their upstream accents.
## Garnet
Garnet preserves Default's slate surface ladder, typography, and semantic status
colors. Its seed is `#990000`, used as the light primary and dark primary
container. Dark primary/text/icons use a lighter `#FFB4AB` for contrast; rose
secondary and copper tertiary roles, their containers, fixed accents, inverse
primary, and surface tint follow the new accent family. Both modes are shipped.
## Theme modes
@@ -87,37 +129,33 @@ doesn't ship — never to a sibling preset.
## The dispatcher
`Theme.kt` is a **pure lookup** — no special-casing. Given a `ThemePreset` and
a dark flag, `themeFor()` returns the preset's `ThemePalette`, then the scheme /
status are read off it (with the mode fallback above):
`ThemeRegistry.kt` maps every `ThemePreset` to one `ThemePalette`. Resolution,
UI selection, and palette tests use this registry. An exact enum/registry coverage
test rejects missing, duplicate, or reordered entries. Android string resources
stay in the UI’s exhaustive label mapping, outside the palette model.
```kotlin
private fun themeFor(preset: ThemePreset): ThemePalette = when (preset) {
ThemePreset.DEFAULT -> DefaultTheme
ThemePreset.MONOCHROME -> MonochromeTheme
// …one line per preset…
}
```
`Theme.kt` reads `preset.palette()` and applies the mode fallback above.
Dynamic (Material You) color on API 31+ can optionally override the preset scheme when
`useDynamicColors = true` (defaults to `false`). Semantic status colors are always resolved from the
active preset via `LocalHermesStatusColors`.
active preset via `LocalHermesStatusColors`. The preset selector stays enabled
and explains that it controls status colors while Material You supplies app colors.
## Adding a new theme
1. **Create** `presets/<Name>Scheme.kt` as a template fill (see above) —
`buildTheme` for a full theme, `buildThemeDarkOnly` / `buildThemeLightOnly`
for single-mode themes. Every status color must be bespoke — no aliasing.
2. **Add 1 line** to `themeFor()` in `Theme.kt` + import the new val.
2. **Register** the new palette in `ThemeRegistry.kt`.
3. **Add the enum entry** `MY_THEME` to `ThemePreset` in `Theme.kt`.
4. **Wire the UI**: add the label + selection in
4. **Wire the UI**: add the exhaustive label mapping in
`ui/settings/components/AppearanceSection.kt` (and any string resource).
5. **Verify**:
```bash
./gradlew ktlintCheck testDebugUnitTest
```
`ThemePaletteTest` asserts >= 3:1 contrast on every shipped mode's error
slot pairs and the ThemeMode invariants — it must stay green.
`ThemePaletteTest` checks text contrast, complete Material slot mapping,
mode-independent fixed roles, registry coverage, and mode/fallback invariants.
## Conventions
@@ -10,38 +10,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.platform.LocalContext
import com.m57.hermescontrol.theme.presets.AmoledTheme
import com.m57.hermescontrol.theme.presets.CatppuccinTheme
import com.m57.hermescontrol.theme.presets.DefaultTheme
import com.m57.hermescontrol.theme.presets.GruvboxTheme
import com.m57.hermescontrol.theme.presets.MonochromeTheme
import com.m57.hermescontrol.theme.presets.NordTheme
import kotlinx.serialization.Serializable
@Serializable
enum class ThemePreference { SYSTEM, LIGHT, DARK }
@Serializable
enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD }
enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD, GARNET }
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
val LocalChatFontScale = compositionLocalOf { 1.0f }
/**
* The 6 preset themes — one file each, all built from the same
* [PaletteTemplate] shape (see `PaletteTemplate.kt`).
*/
private fun themeFor(preset: ThemePreset): ThemePalette =
when (preset) {
ThemePreset.DEFAULT -> DefaultTheme
ThemePreset.MONOCHROME -> MonochromeTheme
ThemePreset.GRUVBOX -> GruvboxTheme
ThemePreset.CATPPUCCIN -> CatppuccinTheme
ThemePreset.AMOLED -> AmoledTheme
ThemePreset.NORD -> NordTheme
}
/**
* Resolve the Material 3 [ColorScheme] for a preset + dark flag.
*
@@ -53,7 +34,7 @@ internal fun resolveColorScheme(
preset: ThemePreset,
darkTheme: Boolean,
): ColorScheme {
val theme = themeFor(preset)
val theme = preset.palette()
// DefaultTheme is FULL — its scheme is never null for either mode.
return theme.schemeFor(darkTheme) ?: requireNotNull(DefaultTheme.schemeFor(darkTheme))
}
@@ -66,7 +47,7 @@ internal fun resolveStatusColors(
preset: ThemePreset,
darkTheme: Boolean,
): HermesStatusColors {
val theme = themeFor(preset)
val theme = preset.palette()
// DefaultTheme is FULL — its status colors are never null for either mode.
return theme.statusFor(darkTheme) ?: requireNotNull(DefaultTheme.statusFor(darkTheme))
}
@@ -0,0 +1,28 @@
package com.m57.hermescontrol.theme
import com.m57.hermescontrol.theme.presets.AmoledTheme
import com.m57.hermescontrol.theme.presets.CatppuccinTheme
import com.m57.hermescontrol.theme.presets.DefaultTheme
import com.m57.hermescontrol.theme.presets.GarnetTheme
import com.m57.hermescontrol.theme.presets.GruvboxTheme
import com.m57.hermescontrol.theme.presets.MonochromeTheme
import com.m57.hermescontrol.theme.presets.NordTheme
internal data class ThemeDefinition(
val preset: ThemePreset,
val palette: ThemePalette,
)
// Regression theme-contract: selection, resolution, and tests share this registry.
internal val ThemeRegistry =
listOf(
ThemeDefinition(ThemePreset.DEFAULT, DefaultTheme),
ThemeDefinition(ThemePreset.MONOCHROME, MonochromeTheme),
ThemeDefinition(ThemePreset.GRUVBOX, GruvboxTheme),
ThemeDefinition(ThemePreset.CATPPUCCIN, CatppuccinTheme),
ThemeDefinition(ThemePreset.AMOLED, AmoledTheme),
ThemeDefinition(ThemePreset.NORD, NordTheme),
ThemeDefinition(ThemePreset.GARNET, GarnetTheme),
)
internal fun ThemePreset.palette(): ThemePalette = ThemeRegistry.single { it.preset == this }.palette
@@ -59,6 +59,20 @@ val AmoledTheme =
onSurface = AmoledTextBright,
surfaceVariant = AmoledContainerHigh,
onSurfaceVariant = AmoledTextDim,
surfaceDim = AmoledFloor,
surfaceBright = AmoledContainerHighest,
primaryFixed = AmoledTextBright,
primaryFixedDim = AmoledTextBright,
onPrimaryFixed = AmoledFloor,
onPrimaryFixedVariant = AmoledFloor,
secondaryFixed = AmoledSecondary,
secondaryFixedDim = AmoledSecondary,
onSecondaryFixed = AmoledFloor,
onSecondaryFixedVariant = AmoledFloor,
tertiaryFixed = AmoledTertiary,
tertiaryFixedDim = AmoledTertiary,
onTertiaryFixed = AmoledFloor,
onTertiaryFixedVariant = AmoledFloor,
surfaceContainerLowest = AmoledFloor,
surfaceContainerLow = AmoledFloor,
surfaceContainer = AmoledContainer,
@@ -19,7 +19,6 @@ private val MochaRed = Color(0xFFF38BA8)
private val MochaText = Color(0xFFCDD6F4)
private val MochaSubtext1 = Color(0xFFBAC2DE)
private val MochaSubtext0 = Color(0xFFA6ADC8)
private val MochaOverlay0 = Color(0xFF6C7086)
private val MochaSurface1 = Color(0xFF45475A)
private val MochaSurface0 = Color(0xFF313244)
private val MochaBase = Color(0xFF1E1E2E)
@@ -31,24 +30,33 @@ private val MochaCrust = Color(0xFF11111B)
// ---------------------------------------------------------------------
private val LatteMauve = Color(0xFF8839EF)
// Derived role, not an upstream Catppuccin swatch: Latte Text/Pink is 3.02:1
// and Text/Green is 2.39:1. Deeper ink reaches 5.88:1 and 4.65:1 respectively.
private val LatteDeepInk = Color(0xFF202334)
// Derived role, not an upstream Catppuccin swatch: Latte Base/Blue is only 4.34:1.
// White/Blue reaches 4.94:1 for normal text.
private val LatteOnBlue = Color(0xFFFFFFFF)
private val LatteBlue = Color(0xFF1E66F5)
private val LattePink = Color(0xFFEA76CB)
private val LatteGreen = Color(0xFF40A02B)
private val LatteYellow = Color(0xFFDF8E1D)
private val LatteRed = Color(0xFFD20F39)
private val LatteText = Color(0xFF4C4F69)
// Official Latte Subtext0 keeps borders visible against Surface0 (3.20:1).
private val LatteSubtext0 = Color(0xFF6C6F85)
private val LatteOverlay0 = Color(0xFF9CA0B0)
private val LatteText = Color(0xFF4C4F69)
private val LatteSurface1 = Color(0xFFBCC0CC)
private val LatteSurface0 = Color(0xFFCCD0DA)
private val LatteBase = Color(0xFFEFF1F5)
private val LatteMantle = Color(0xFFE6E9EF)
private val LatteCrust = Color(0xFFDCE0E8)
// Bespoke tint used only for the light primary container — not part of
// the official Catppuccin swatch set, kept local to this file.
private val LattePrimaryContainer = Color(0xFFE6CAFA)
private val LatteOnPrimaryContainer = Color(0xFF380075)
// Regression theme-contract: fixed accents use Mocha in both modes. Fixed/Dim
// share official accents rather than inventing tonal steps; on-roles use Crust.
// Latte surface containers stop at Surface0 so normal text stays >= 4.5:1.
// Upstream: https://github.com/catppuccin/palette/blob/main/palette.json
/** Catppuccin theme — Mocha (dark) / Latte (light). */
val CatppuccinTheme =
@@ -73,6 +81,20 @@ val CatppuccinTheme =
onSurface = MochaText,
surfaceVariant = MochaSurface0,
onSurfaceVariant = MochaSubtext0,
surfaceDim = MochaBase,
surfaceBright = MochaSurface1,
primaryFixed = MochaMauve,
primaryFixedDim = MochaMauve,
onPrimaryFixed = MochaCrust,
onPrimaryFixedVariant = MochaCrust,
secondaryFixed = MochaBlue,
secondaryFixedDim = MochaBlue,
onSecondaryFixed = MochaCrust,
onSecondaryFixedVariant = MochaCrust,
tertiaryFixed = MochaPink,
tertiaryFixedDim = MochaPink,
onTertiaryFixed = MochaCrust,
onTertiaryFixedVariant = MochaCrust,
surfaceContainerLowest = MochaCrust,
surfaceContainerLow = MochaMantle,
surfaceContainer = MochaBase,
@@ -81,7 +103,7 @@ val CatppuccinTheme =
inverseSurface = MochaText,
inverseOnSurface = MochaBase,
inversePrimary = LatteMauve,
outline = MochaOverlay0,
outline = MochaSubtext0,
outlineVariant = MochaSurface1,
scrim = MochaCrust,
status =
@@ -105,48 +127,62 @@ val CatppuccinTheme =
PaletteColors(
primary = LatteMauve,
onPrimary = LatteBase,
primaryContainer = LattePrimaryContainer,
onPrimaryContainer = LatteOnPrimaryContainer,
primaryContainer = LatteBase,
onPrimaryContainer = LatteMauve,
secondary = LatteBlue,
onSecondary = LatteBase,
onSecondary = LatteOnBlue,
secondaryContainer = LatteMantle,
onSecondaryContainer = LatteText,
tertiary = LattePink,
onTertiary = LatteBase,
tertiaryContainer = LatteSurface1,
onTertiary = LatteDeepInk,
tertiaryContainer = LatteSurface0,
onTertiaryContainer = LatteText,
background = LatteBase,
onBackground = LatteText,
surface = LatteBase,
onSurface = LatteText,
surfaceVariant = LatteSurface0,
onSurfaceVariant = LatteSubtext0,
onSurfaceVariant = LatteText,
surfaceDim = LatteSurface0,
surfaceBright = LatteBase,
primaryFixed = MochaMauve,
primaryFixedDim = MochaMauve,
onPrimaryFixed = MochaCrust,
onPrimaryFixedVariant = MochaCrust,
secondaryFixed = MochaBlue,
secondaryFixedDim = MochaBlue,
onSecondaryFixed = MochaCrust,
onSecondaryFixedVariant = MochaCrust,
tertiaryFixed = MochaPink,
tertiaryFixedDim = MochaPink,
onTertiaryFixed = MochaCrust,
onTertiaryFixedVariant = MochaCrust,
surfaceContainerLowest = LatteCrust,
surfaceContainerLow = LatteMantle,
surfaceContainer = LatteBase,
surfaceContainerHigh = LatteSurface0,
surfaceContainerHighest = LatteSurface1,
surfaceContainerHighest = LatteSurface0,
inverseSurface = LatteText,
inverseOnSurface = LatteBase,
inversePrimary = MochaMauve,
outline = LatteOverlay0,
outline = LatteSubtext0,
outlineVariant = LatteSurface1,
scrim = LatteCrust,
status =
HermesStatusColors(
success = LatteGreen,
successContainer = LatteSurface0,
onSuccess = LatteBase,
onSuccess = LatteDeepInk,
warning = LatteYellow,
warningContainer = LatteSurface0,
onWarning = LatteBase,
onWarning = LatteDeepInk,
error = LatteRed,
errorContainer = LatteSurface0,
onError = LatteBase,
onErrorContainer = LatteRed,
onErrorContainer = LatteText,
info = LatteBlue,
infoContainer = LatteSurface0,
onInfo = LatteBase,
onInfo = LatteOnBlue,
),
),
)
@@ -73,6 +73,20 @@ val DefaultTheme =
onSurface = SlateDarkFg1,
surfaceVariant = SlateDarkBg1,
onSurfaceVariant = SlateDarkFg3,
surfaceDim = SlateDarkBg0,
surfaceBright = SlateDarkBg4,
primaryFixed = SlateBrightTeal,
primaryFixedDim = SlateBrightTeal,
onPrimaryFixed = SlateDarkBg0,
onPrimaryFixedVariant = SlateDarkBg0,
secondaryFixed = SlateBrightCyan,
secondaryFixedDim = SlateBrightCyan,
onSecondaryFixed = SlateDarkBg0,
onSecondaryFixedVariant = SlateDarkBg0,
tertiaryFixed = SlateBrightBlue,
tertiaryFixedDim = SlateBrightBlue,
onTertiaryFixed = SlateDarkBg0,
onTertiaryFixedVariant = SlateDarkBg0,
surfaceContainerLowest = SlateDarkBg0,
surfaceContainerLow = SlateDarkBg1,
surfaceContainer = SlateDarkBg2,
@@ -121,6 +135,20 @@ val DefaultTheme =
onSurface = SlateLightFg1,
surfaceVariant = SlateLightBg1,
onSurfaceVariant = SlateLightFg3,
surfaceDim = SlateLightBg4,
surfaceBright = SlateLightBg0,
primaryFixed = SlateBrightTeal,
primaryFixedDim = SlateBrightTeal,
onPrimaryFixed = SlateDarkBg0,
onPrimaryFixedVariant = SlateDarkBg0,
secondaryFixed = SlateBrightCyan,
secondaryFixedDim = SlateBrightCyan,
onSecondaryFixed = SlateDarkBg0,
onSecondaryFixedVariant = SlateDarkBg0,
tertiaryFixed = SlateBrightBlue,
tertiaryFixedDim = SlateBrightBlue,
onTertiaryFixed = SlateDarkBg0,
onTertiaryFixedVariant = SlateDarkBg0,
surfaceContainerLowest = SlateLightBg0,
surfaceContainerLow = SlateLightBg1,
surfaceContainer = SlateLightBg2,
@@ -129,7 +157,7 @@ val DefaultTheme =
inverseSurface = SlateLightFg1,
inverseOnSurface = SlateLightBg0,
inversePrimary = SlateBrightTeal,
outline = SlateGray,
outline = SlateLightFg3,
outlineVariant = SlateLightBg3,
scrim = SlateLightBg0,
status =
@@ -0,0 +1,185 @@
package com.m57.hermescontrol.theme.presets
import androidx.compose.ui.graphics.Color
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.PaletteColors
import com.m57.hermescontrol.theme.buildTheme
// ---------------------------------------------------------------------
// Default slate surfaces retained; accents use the Garnet red family.
// Garnet Dark — named swatches
// ---------------------------------------------------------------------
private val GarnetDarkBg0 = Color(0xFF0F1416)
private val GarnetDarkBg1 = Color(0xFF171C1E)
private val GarnetDarkBg2 = Color(0xFF1B2022)
private val GarnetDarkBg3 = Color(0xFF252B2D)
private val GarnetDarkBg4 = Color(0xFF303638)
private val GarnetDarkFg1 = Color(0xFFDEE3E5)
private val GarnetDarkFg3 = Color(0xFFBEC8CA)
private val GarnetGray = Color(0xFF889294) // same neutral gray in both modes
// "Bright" accents — high-visibility variants against dark surfaces.
private val GarnetBrightCopper = Color(0xFFEABD96)
private val GarnetBrightRose = Color(0xFFE5BDB8)
private val GarnetBrightGreen = Color(0xFF81D692)
private val GarnetBrightYellow = Color(0xFFECC248)
private val GarnetBrightRed = Color(0xFFFFB4AB)
private val GarnetBrightInfo = Color(0xFF8ECEFF)
// ---------------------------------------------------------------------
// Garnet Light — named swatches
// ---------------------------------------------------------------------
private val GarnetLightBg0 = Color(0xFFF5FAFC)
private val GarnetLightBg1 = Color(0xFFEFF4F6)
private val GarnetLightBg2 = Color(0xFFE9EEF0)
private val GarnetLightBg3 = Color(0xFFE3E8EA)
private val GarnetLightBg4 = Color(0xFFDEE3E5)
private val GarnetLightFg1 = Color(0xFF171C1E)
private val GarnetLightFg3 = Color(0xFF3F484A)
// "Faded" accents — muted variants for light background contrast.
private val GarnetAccent = Color(0xFF990000)
private val GarnetFadedCopper = Color(0xFF805531)
private val GarnetFadedRose = Color(0xFF775651)
private val GarnetFadedGreen = Color(0xFF126D2B)
private val GarnetFadedYellow = Color(0xFF725C00)
private val GarnetFadedRed = Color(0xFFBA1A1A)
private val GarnetFadedInfo = Color(0xFF006393)
// #990000 is the seed and light primary. Its contrast on the dark canvas is only
// 2.08:1; #FFB4AB supplies readable dark-mode text/icons (10.93:1).
private val GarnetLightRedContainer = Color(0xFFFFDAD5)
private val GarnetDarkRoseContainer = Color(0xFF573935)
private val GarnetDarkCopperContainer = Color(0xFF634025)
private val GarnetLightCopperContainer = Color(0xFFFFDCC1)
/** Garnet — Default slate surfaces with a #990000 red accent and matching warm tones. */
val GarnetTheme =
buildTheme(
dark =
PaletteColors(
primary = GarnetBrightRed,
onPrimary = GarnetDarkBg0,
primaryContainer = GarnetAccent,
onPrimaryContainer = GarnetLightBg0,
secondary = GarnetBrightRose,
onSecondary = GarnetDarkBg0,
secondaryContainer = GarnetDarkRoseContainer,
onSecondaryContainer = GarnetBrightRose,
tertiary = GarnetBrightCopper,
onTertiary = GarnetDarkBg0,
tertiaryContainer = GarnetDarkCopperContainer,
onTertiaryContainer = GarnetBrightCopper,
background = GarnetDarkBg0,
onBackground = GarnetDarkFg1,
surface = GarnetDarkBg0,
onSurface = GarnetDarkFg1,
surfaceVariant = GarnetDarkBg1,
onSurfaceVariant = GarnetDarkFg3,
surfaceDim = GarnetDarkBg0,
surfaceBright = GarnetDarkBg4,
primaryFixed = GarnetBrightRed,
primaryFixedDim = GarnetBrightRed,
onPrimaryFixed = GarnetDarkBg0,
onPrimaryFixedVariant = GarnetDarkBg0,
secondaryFixed = GarnetBrightRose,
secondaryFixedDim = GarnetBrightRose,
onSecondaryFixed = GarnetDarkBg0,
onSecondaryFixedVariant = GarnetDarkBg0,
tertiaryFixed = GarnetBrightCopper,
tertiaryFixedDim = GarnetBrightCopper,
onTertiaryFixed = GarnetDarkBg0,
onTertiaryFixedVariant = GarnetDarkBg0,
surfaceContainerLowest = GarnetDarkBg0,
surfaceContainerLow = GarnetDarkBg1,
surfaceContainer = GarnetDarkBg2,
surfaceContainerHigh = GarnetDarkBg3,
surfaceContainerHighest = GarnetDarkBg4,
inverseSurface = GarnetDarkFg1,
inverseOnSurface = GarnetDarkBg0,
inversePrimary = GarnetAccent,
outline = GarnetGray,
outlineVariant = GarnetDarkBg3,
scrim = GarnetDarkBg0,
status =
HermesStatusColors(
success = GarnetBrightGreen,
successContainer = GarnetDarkBg3,
onSuccess = GarnetDarkBg0,
warning = GarnetBrightYellow,
warningContainer = GarnetDarkBg3,
onWarning = GarnetDarkBg0,
error = GarnetBrightRed,
errorContainer = GarnetDarkBg3,
onError = GarnetDarkBg0,
onErrorContainer = GarnetBrightRed,
info = GarnetBrightInfo,
infoContainer = GarnetDarkBg3,
onInfo = GarnetDarkBg0,
),
),
light =
PaletteColors(
primary = GarnetAccent,
onPrimary = GarnetLightBg0,
primaryContainer = GarnetLightRedContainer,
onPrimaryContainer = GarnetAccent,
secondary = GarnetFadedRose,
onSecondary = GarnetLightBg0,
secondaryContainer = GarnetLightRedContainer,
onSecondaryContainer = GarnetFadedRose,
tertiary = GarnetFadedCopper,
onTertiary = GarnetLightBg0,
tertiaryContainer = GarnetLightCopperContainer,
onTertiaryContainer = GarnetFadedCopper,
background = GarnetLightBg0,
onBackground = GarnetLightFg1,
surface = GarnetLightBg0,
onSurface = GarnetLightFg1,
surfaceVariant = GarnetLightBg1,
onSurfaceVariant = GarnetLightFg3,
surfaceDim = GarnetLightBg4,
surfaceBright = GarnetLightBg0,
primaryFixed = GarnetBrightRed,
primaryFixedDim = GarnetBrightRed,
onPrimaryFixed = GarnetDarkBg0,
onPrimaryFixedVariant = GarnetDarkBg0,
secondaryFixed = GarnetBrightRose,
secondaryFixedDim = GarnetBrightRose,
onSecondaryFixed = GarnetDarkBg0,
onSecondaryFixedVariant = GarnetDarkBg0,
tertiaryFixed = GarnetBrightCopper,
tertiaryFixedDim = GarnetBrightCopper,
onTertiaryFixed = GarnetDarkBg0,
onTertiaryFixedVariant = GarnetDarkBg0,
surfaceContainerLowest = GarnetLightBg0,
surfaceContainerLow = GarnetLightBg1,
surfaceContainer = GarnetLightBg2,
surfaceContainerHigh = GarnetLightBg3,
surfaceContainerHighest = GarnetLightBg4,
inverseSurface = GarnetLightFg1,
inverseOnSurface = GarnetLightBg0,
inversePrimary = GarnetBrightRed,
outline = GarnetLightFg3,
outlineVariant = GarnetLightBg3,
scrim = GarnetLightBg0,
status =
HermesStatusColors(
success = GarnetFadedGreen,
successContainer = GarnetLightBg3,
onSuccess = GarnetLightBg0,
warning = GarnetFadedYellow,
warningContainer = GarnetLightBg3,
onWarning = GarnetLightBg0,
error = GarnetFadedRed,
errorContainer = GarnetLightBg3,
onError = GarnetLightBg0,
onErrorContainer = GarnetFadedRed,
info = GarnetFadedInfo,
infoContainer = GarnetLightBg3,
onInfo = GarnetLightBg0,
),
),
)
@@ -9,18 +9,20 @@ import com.m57.hermescontrol.theme.buildTheme
// Gruvbox Dark (medium contrast) — named swatches
// ---------------------------------------------------------------------
// Official hard-contrast bg0; medium bg0/red only reaches 4.29:1.
private val GruvboxDarkInk = Color(0xFF1D2021)
private val GruvboxDarkBg0 = Color(0xFF282828)
private val GruvboxDarkBg1 = Color(0xFF3C3836)
private val GruvboxDarkBg2 = Color(0xFF504945)
private val GruvboxDarkBg3 = Color(0xFF665C54)
private val GruvboxDarkBg4 = Color(0xFF7C6F64)
private val GruvboxDarkFg1 = Color(0xFFEBDBB2)
private val GruvboxDarkFg2 = Color(0xFFD5C4A1)
private val GruvboxDarkFg3 = Color(0xFFBDAE93)
private val GruvboxGray = Color(0xFF928374) // same neutral gray in both modes
// "Bright" accents — the variants gruvbox uses against a dark background.
private val GruvboxBrightRed = Color(0xFFFB4934)
// Derived role, not an upstream swatch: bright red/bg0 is only 4.29:1.
// Lighter red reaches 4.92:1 for error text and field labels on bg0.
private val GruvboxErrorText = Color(0xFFFF5F4F)
private val GruvboxBrightGreen = Color(0xFFB8BB26)
private val GruvboxBrightYellow = Color(0xFFFABD2F)
private val GruvboxBrightBlue = Color(0xFF83A598)
@@ -35,7 +37,6 @@ private val GruvboxLightBg0 = Color(0xFFFBF1C7)
private val GruvboxLightBg1 = Color(0xFFEBDBB2)
private val GruvboxLightBg2 = Color(0xFFD5C4A1)
private val GruvboxLightBg3 = Color(0xFFBDAE93)
private val GruvboxLightBg4 = Color(0xFFA89984)
private val GruvboxLightFg1 = Color(0xFF3C3836)
private val GruvboxLightFg2 = Color(0xFF504945)
private val GruvboxLightFg3 = Color(0xFF665C54)
@@ -43,28 +44,38 @@ private val GruvboxLightFg3 = Color(0xFF665C54)
// "Faded" accents — the muted variants gruvbox uses against a light
// background, so contrast holds up without the neon dark-mode saturation.
private val GruvboxFadedRed = Color(0xFF9D0006)
// Derived roles, not upstream Gruvbox swatches: light bg0/faded green is 4.29:1;
// white/green reaches 4.86:1. Hard dark bg0/faded yellow is below 4.5:1;
// deeper neutral ink/yellow reaches 4.97:1.
private val GruvboxOnFadedGreen = Color(0xFFFFFFFF)
private val GruvboxOnFadedYellow = Color(0xFF121212)
private val GruvboxFadedGreen = Color(0xFF79740E)
private val GruvboxFadedYellow = Color(0xFFB57614)
private val GruvboxFadedBlue = Color(0xFF076678)
private val GruvboxFadedPurple = Color(0xFF8F3F71)
private val GruvboxFadedOrange = Color(0xFFAF3A03)
/** Gruvbox theme — bright accents on dark bg0–bg4, faded accents on light bg0–bg4. */
// Regression theme-contract: fixed roles reuse bright accents in both modes;
// Fixed/Dim share a swatch. Containers stop at bg3 for >= 4.5:1 body text.
// Upstream: https://github.com/morhetz/gruvbox/blob/master/colors/gruvbox.vim
/** Gruvbox theme — bright accents on dark bg0–bg3, faded accents on light bg0–bg3. */
val GruvboxTheme =
buildTheme(
dark =
PaletteColors(
primary = GruvboxBrightOrange,
onPrimary = GruvboxDarkBg0,
primaryContainer = GruvboxDarkBg4,
primaryContainer = GruvboxDarkBg2,
onPrimaryContainer = GruvboxDarkFg1,
secondary = GruvboxBrightBlue,
onSecondary = GruvboxDarkBg0,
secondaryContainer = GruvboxDarkBg3,
secondaryContainer = GruvboxDarkBg2,
onSecondaryContainer = GruvboxDarkFg2,
tertiary = GruvboxBrightPurple,
onTertiary = GruvboxDarkBg0,
tertiaryContainer = GruvboxDarkBg4,
tertiaryContainer = GruvboxDarkBg2,
onTertiaryContainer = GruvboxDarkFg1,
background = GruvboxDarkBg0,
onBackground = GruvboxDarkFg1,
@@ -72,15 +83,29 @@ val GruvboxTheme =
onSurface = GruvboxDarkFg1,
surfaceVariant = GruvboxDarkBg1,
onSurfaceVariant = GruvboxDarkFg3,
surfaceDim = GruvboxDarkBg0,
surfaceBright = GruvboxDarkBg3,
primaryFixed = GruvboxBrightOrange,
primaryFixedDim = GruvboxBrightOrange,
onPrimaryFixed = GruvboxDarkBg0,
onPrimaryFixedVariant = GruvboxDarkBg0,
secondaryFixed = GruvboxBrightBlue,
secondaryFixedDim = GruvboxBrightBlue,
onSecondaryFixed = GruvboxDarkBg0,
onSecondaryFixedVariant = GruvboxDarkBg0,
tertiaryFixed = GruvboxBrightPurple,
tertiaryFixedDim = GruvboxBrightPurple,
onTertiaryFixed = GruvboxDarkBg0,
onTertiaryFixedVariant = GruvboxDarkBg0,
surfaceContainerLowest = GruvboxDarkBg0,
surfaceContainerLow = GruvboxDarkBg1,
surfaceContainer = GruvboxDarkBg2,
surfaceContainerHigh = GruvboxDarkBg3,
surfaceContainerHighest = GruvboxDarkBg4,
surfaceContainerHighest = GruvboxDarkBg3,
inverseSurface = GruvboxDarkFg1,
inverseOnSurface = GruvboxDarkBg0,
inversePrimary = GruvboxFadedOrange,
outline = GruvboxGray,
outline = GruvboxDarkFg2,
outlineVariant = GruvboxDarkBg3,
scrim = GruvboxDarkBg0,
status =
@@ -91,9 +116,9 @@ val GruvboxTheme =
warning = GruvboxBrightYellow,
warningContainer = GruvboxDarkBg3,
onWarning = GruvboxDarkBg0,
error = GruvboxBrightRed,
error = GruvboxErrorText,
errorContainer = GruvboxDarkBg3,
onError = GruvboxDarkBg0,
onError = GruvboxDarkInk,
onErrorContainer = GruvboxDarkFg1,
info = GruvboxBrightBlue,
infoContainer = GruvboxDarkBg3,
@@ -104,15 +129,15 @@ val GruvboxTheme =
PaletteColors(
primary = GruvboxFadedOrange,
onPrimary = GruvboxLightBg0,
primaryContainer = GruvboxLightBg4,
primaryContainer = GruvboxLightBg2,
onPrimaryContainer = GruvboxLightFg1,
secondary = GruvboxFadedBlue,
onSecondary = GruvboxLightBg0,
secondaryContainer = GruvboxLightBg3,
secondaryContainer = GruvboxLightBg2,
onSecondaryContainer = GruvboxLightFg2,
tertiary = GruvboxFadedPurple,
onTertiary = GruvboxLightBg0,
tertiaryContainer = GruvboxLightBg4,
tertiaryContainer = GruvboxLightBg2,
onTertiaryContainer = GruvboxLightFg1,
background = GruvboxLightBg0,
onBackground = GruvboxLightFg1,
@@ -120,29 +145,43 @@ val GruvboxTheme =
onSurface = GruvboxLightFg1,
surfaceVariant = GruvboxLightBg1,
onSurfaceVariant = GruvboxLightFg3,
surfaceDim = GruvboxLightBg3,
surfaceBright = GruvboxLightBg0,
primaryFixed = GruvboxBrightOrange,
primaryFixedDim = GruvboxBrightOrange,
onPrimaryFixed = GruvboxDarkBg0,
onPrimaryFixedVariant = GruvboxDarkBg0,
secondaryFixed = GruvboxBrightBlue,
secondaryFixedDim = GruvboxBrightBlue,
onSecondaryFixed = GruvboxDarkBg0,
onSecondaryFixedVariant = GruvboxDarkBg0,
tertiaryFixed = GruvboxBrightPurple,
tertiaryFixedDim = GruvboxBrightPurple,
onTertiaryFixed = GruvboxDarkBg0,
onTertiaryFixedVariant = GruvboxDarkBg0,
surfaceContainerLowest = GruvboxLightBg0,
surfaceContainerLow = GruvboxLightBg1,
surfaceContainer = GruvboxLightBg2,
surfaceContainerHigh = GruvboxLightBg3,
surfaceContainerHighest = GruvboxLightBg4,
surfaceContainerHighest = GruvboxLightBg3,
inverseSurface = GruvboxLightFg1,
inverseOnSurface = GruvboxLightBg0,
inversePrimary = GruvboxBrightOrange,
outline = GruvboxGray,
outline = GruvboxLightFg2,
outlineVariant = GruvboxLightBg3,
scrim = GruvboxLightBg0,
status =
HermesStatusColors(
success = GruvboxFadedGreen,
successContainer = GruvboxLightBg3,
onSuccess = GruvboxLightBg0,
onSuccess = GruvboxOnFadedGreen,
warning = GruvboxFadedYellow,
warningContainer = GruvboxLightBg3,
onWarning = GruvboxLightBg0,
onWarning = GruvboxOnFadedYellow,
error = GruvboxFadedRed,
errorContainer = GruvboxLightBg3,
onError = GruvboxLightBg0,
onErrorContainer = GruvboxFadedRed,
onErrorContainer = GruvboxLightFg1,
info = GruvboxFadedBlue,
infoContainer = GruvboxLightBg3,
onInfo = GruvboxLightBg0,
@@ -32,7 +32,6 @@ private val MonoLightBg = Color(0xFFFAFAFA) // background, surface, containerLow
private val MonoLightContainer = Color(0xFFF0F0F0) // surfaceContainer
private val MonoLightContainerHigh = Color(0xFFE0E0E0) // surfaceContainerHigh, surfaceVariant, status containers
private val MonoLightContainerHighest = Color(0xFFD1D1D1) // surfaceContainerHighest, primaryContainer
private val MonoLightOutline = Color(0xFF808080)
private val MonoLightOutlineVariant = Color(0xFFCCCCCC)
private val MonoLightTextDim = Color(0xFF4D4D4D) // onSurfaceVariant
private val MonoLightTextBright = Color(0xFF121212) // onBackground/onSurface/inverseSurface/primary/error
@@ -71,6 +70,20 @@ val MonochromeTheme =
onSurface = MonoDarkTextBright,
surfaceVariant = MonoDarkContainerHigh,
onSurfaceVariant = MonoDarkTextDim,
surfaceDim = MonoDarkBg,
surfaceBright = MonoDarkContainerHighest,
primaryFixed = MonoDarkTextBright,
primaryFixedDim = MonoDarkTextBright,
onPrimaryFixed = MonoDarkBg,
onPrimaryFixedVariant = MonoDarkBg,
secondaryFixed = MonoDarkSecondary,
secondaryFixedDim = MonoDarkSecondary,
onSecondaryFixed = MonoDarkBg,
onSecondaryFixedVariant = MonoDarkBg,
tertiaryFixed = MonoDarkTertiary,
tertiaryFixedDim = MonoDarkTertiary,
onTertiaryFixed = MonoDarkBg,
onTertiaryFixedVariant = MonoDarkBg,
surfaceContainerLowest = MonoDarkFloor,
surfaceContainerLow = MonoDarkBg,
surfaceContainer = MonoDarkContainer,
@@ -112,13 +125,27 @@ val MonochromeTheme =
tertiary = MonoLightTertiary,
onTertiary = MonoLightBg,
tertiaryContainer = MonoLightContainerHigh,
onTertiaryContainer = MonoLightTertiary,
onTertiaryContainer = MonoLightSecondary,
background = MonoLightBg,
onBackground = MonoLightTextBright,
surface = MonoLightBg,
onSurface = MonoLightTextBright,
surfaceVariant = MonoLightContainerHigh,
onSurfaceVariant = MonoLightTextDim,
surfaceDim = MonoLightContainerHighest,
surfaceBright = MonoLightBg,
primaryFixed = MonoDarkTextBright,
primaryFixedDim = MonoDarkTextBright,
onPrimaryFixed = MonoDarkBg,
onPrimaryFixedVariant = MonoDarkBg,
secondaryFixed = MonoDarkSecondary,
secondaryFixedDim = MonoDarkSecondary,
onSecondaryFixed = MonoDarkBg,
onSecondaryFixedVariant = MonoDarkBg,
tertiaryFixed = MonoDarkTertiary,
tertiaryFixedDim = MonoDarkTertiary,
onTertiaryFixed = MonoDarkBg,
onTertiaryFixedVariant = MonoDarkBg,
surfaceContainerLowest = MonoLightCeiling,
surfaceContainerLow = MonoLightBg,
surfaceContainer = MonoLightContainer,
@@ -127,7 +154,7 @@ val MonochromeTheme =
inverseSurface = MonoLightTextBright,
inverseOnSurface = MonoLightBg,
inversePrimary = MonoDarkTextBright,
outline = MonoLightOutline,
outline = MonoLightTertiary,
outlineVariant = MonoLightOutlineVariant,
scrim = Color.Black,
status =
@@ -144,7 +171,7 @@ val MonochromeTheme =
onErrorContainer = MonoLightTextBright,
info = MonoLightInfo,
infoContainer = MonoLightContainerHigh,
onInfo = MonoLightBg,
onInfo = MonoLightTextBright,
),
),
)
@@ -11,9 +11,12 @@ import com.m57.hermescontrol.theme.buildTheme
// supply the surfaces; Frost (7–10) and Aurora (11–15) supply the
// accents. Unlike Gruvbox, Nord defines one accent set — not a
// separate bright/faded pair per mode — so the same accent swatches
// are reused in both palettes below.
// are reused for fixed roles; primary/error need documented accessible tones.
// ---------------------------------------------------------------------
// Derived role, not an upstream Nord swatch: nord0/red is 3.05:1 and
// nord0/purple is 4.41:1. Darker Polar Night ink meets 4.5:1 on both.
private val NordPolarInk = Color(0xFF0F1218)
private val Nord0 = Color(0xFF2E3440) // Polar Night — darkest
private val Nord1 = Color(0xFF3B4252)
private val Nord2 = Color(0xFF434C5E)
@@ -23,11 +26,23 @@ private val Nord5 = Color(0xFFE5E9F0)
private val Nord6 = Color(0xFFECEFF4) // Snow Storm — lightest
private val Nord8 = Color(0xFF88C0D0) // Frost — primary accent
private val Nord9 = Color(0xFF81A1C1) // Frost — secondary / info
private val Nord11 = Color(0xFFBF616A) // Aurora red — error
// Derived roles, not upstream Nord swatches. Nord10/Snow Storm is only 3.50:1;
// deeper Frost ink reaches 4.92:1 on nord6 for primary text/focus labels.
private val NordFrostInk = Color(0xFF486991)
// Official Aurora red/nord0 is 3.05:1; a brighter red reaches 4.67:1.
private val NordErrorBright = Color(0xFFDE858F)
// Official Aurora red/nord6 is 3.55:1; deeper red reaches 5.69:1.
private val NordErrorInk = Color(0xFFA53547)
private val Nord13 = Color(0xFFEBCB8B) // Aurora yellow — warning
private val Nord14 = Color(0xFFA3BE8C) // Aurora green — success
private val Nord15 = Color(0xFFB48EAD) // Aurora purple — tertiary
// Fixed/Dim reuse each official accent in both modes (no invented tonal ladder).
// Upstream: https://www.nordtheme.com/docs/colors-and-palettes/
/**
* Nord theme.
*
@@ -36,12 +51,11 @@ private val Nord15 = Color(0xFFB48EAD) // Aurora purple — tertiary
* tiers on each side share a value (surfaceContainerHigh ==
* surfaceContainerHighest) rather than inventing an off-palette hex.
*
* Frost/Aurora accents are pastel by design, so onPrimary/onSecondary/
* onTertiary/status "on" colors use Polar Night (dark text) in *both*
* modes — reusing Snow Storm for light-mode "on" text (the convention in
* Pastel Frost/Aurora fills use dark ink in both modes — reusing
* Snow Storm for light-mode "on" text (the convention in
* the Catppuccin/Gruvbox presets) would fail contrast against colors like
* Aurora yellow, which stays light even without a separate light-mode
* variant.
* variant. Light primary/error use Snow Storm ink on their darker fills.
*/
val NordTheme =
buildTheme(
@@ -56,7 +70,7 @@ val NordTheme =
secondaryContainer = Nord2,
onSecondaryContainer = Nord4,
tertiary = Nord15,
onTertiary = Nord0,
onTertiary = NordPolarInk,
tertiaryContainer = Nord3,
onTertiaryContainer = Nord6,
background = Nord0,
@@ -65,6 +79,20 @@ val NordTheme =
onSurface = Nord6,
surfaceVariant = Nord1,
onSurfaceVariant = Nord4,
surfaceDim = Nord0,
surfaceBright = Nord3,
primaryFixed = Nord8,
primaryFixedDim = Nord8,
onPrimaryFixed = Nord0,
onPrimaryFixedVariant = Nord0,
secondaryFixed = Nord9,
secondaryFixedDim = Nord9,
onSecondaryFixed = Nord0,
onSecondaryFixedVariant = Nord0,
tertiaryFixed = Nord15,
tertiaryFixedDim = Nord15,
onTertiaryFixed = NordPolarInk,
onTertiaryFixedVariant = NordPolarInk,
surfaceContainerLowest = Nord0,
surfaceContainerLow = Nord1,
surfaceContainer = Nord2,
@@ -72,8 +100,8 @@ val NordTheme =
surfaceContainerHighest = Nord3,
inverseSurface = Nord6,
inverseOnSurface = Nord0,
inversePrimary = Nord8,
outline = Nord3,
inversePrimary = NordFrostInk,
outline = Nord4,
outlineVariant = Nord2,
scrim = Nord0,
status =
@@ -84,9 +112,9 @@ val NordTheme =
warning = Nord13,
warningContainer = Nord2,
onWarning = Nord0,
error = Nord11,
error = NordErrorBright,
errorContainer = Nord2,
onError = Nord0,
onError = NordPolarInk,
onErrorContainer = Nord6,
info = Nord9,
infoContainer = Nord2,
@@ -95,8 +123,8 @@ val NordTheme =
),
light =
PaletteColors(
primary = Nord8,
onPrimary = Nord0,
primary = NordFrostInk,
onPrimary = Nord6,
primaryContainer = Nord4,
onPrimaryContainer = Nord0,
secondary = Nord9,
@@ -104,7 +132,7 @@ val NordTheme =
secondaryContainer = Nord5,
onSecondaryContainer = Nord1,
tertiary = Nord15,
onTertiary = Nord0,
onTertiary = NordPolarInk,
tertiaryContainer = Nord4,
onTertiaryContainer = Nord0,
background = Nord6,
@@ -113,6 +141,20 @@ val NordTheme =
onSurface = Nord0,
surfaceVariant = Nord5,
onSurfaceVariant = Nord2,
surfaceDim = Nord4,
surfaceBright = Nord6,
primaryFixed = Nord8,
primaryFixedDim = Nord8,
onPrimaryFixed = Nord0,
onPrimaryFixedVariant = Nord0,
secondaryFixed = Nord9,
secondaryFixedDim = Nord9,
onSecondaryFixed = Nord0,
onSecondaryFixedVariant = Nord0,
tertiaryFixed = Nord15,
tertiaryFixedDim = Nord15,
onTertiaryFixed = NordPolarInk,
onTertiaryFixedVariant = NordPolarInk,
surfaceContainerLowest = Nord6,
surfaceContainerLow = Nord5,
surfaceContainer = Nord5,
@@ -132,10 +174,10 @@ val NordTheme =
warning = Nord13,
warningContainer = Nord5,
onWarning = Nord0,
error = Nord11,
error = NordErrorInk,
errorContainer = Nord5,
onError = Nord0,
onErrorContainer = Nord11,
onError = Nord6,
onErrorContainer = Nord0,
info = Nord9,
infoContainer = Nord5,
onInfo = Nord0,
@@ -16,7 +16,9 @@ import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams
import com.m57.hermescontrol.data.ws.contract.ProfilesListParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.CoroutineDispatcher
@@ -29,6 +31,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
@@ -243,14 +247,8 @@ class BotsViewModel(
// First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars).
var profilesWithMeta: List<ProfileInfo>? = null
try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
val jsonElement =
when (rpcResult) {
is JsonElement -> rpcResult
null -> null
else -> rpcResult.toJsonElement()
}
if (jsonElement != null) {
val jsonElement = HermesWsClient.call(RpcMethods.PROFILES_LIST, ProfilesListParams)
if (jsonElement !is JsonNull) {
val resp = OkHttpProvider.json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
if (!resp.profiles.isNullOrEmpty()) {
profilesWithMeta = resp.profiles
@@ -362,7 +360,13 @@ class BotsViewModel(
),
)
val botSoul = composeBotSoul(name, title, description)
wsClientConfigureBot(name, botMeta, soul = botSoul)
// Fire-and-forget as before: the metadata write never gated the reload or onSuccess.
launch {
try {
wsClientConfigureBot(name, botMeta, soul = botSoul)
} catch (_: Exception) {
}
}
loadBots()
onSuccess()
} else {
@@ -396,7 +400,7 @@ class BotsViewModel(
),
)
try {
wsClientConfigureBot(name, updatedMeta).await()
wsClientConfigureBot(name, updatedMeta)
} catch (_: Exception) {
}
loadBots()
@@ -440,7 +444,7 @@ class BotsViewModel(
group = existingGroups.firstOrNull() ?: groupName,
)
try {
wsClientConfigureBot(name, updatedMeta).await()
wsClientConfigureBot(name, updatedMeta)
} catch (_: Exception) {
}
}
@@ -475,14 +479,13 @@ class BotsViewModel(
rooms = updatedRooms,
deleted = updatedDeleted,
)
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
),
).await()
HermesWsClient.call(
RpcMethods.PROFILES_CONFIGURE,
ProfilesConfigureParams(
name = defaultProfile.name,
uiMeta = mapOf("hermes-bots-groups" to newSnapshot.toMap()).toJsonObject(),
),
)
}
} catch (_: Exception) {
}
@@ -509,7 +512,7 @@ class BotsViewModel(
group = filtered.firstOrNull(),
)
try {
wsClientConfigureBot(bot.name, updatedMeta).await()
wsClientConfigureBot(bot.name, updatedMeta)
} catch (_: Exception) {
}
}
@@ -540,14 +543,13 @@ class BotsViewModel(
rooms = updatedRooms,
deleted = deletedMap,
)
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()),
),
).await()
HermesWsClient.call(
RpcMethods.PROFILES_CONFIGURE,
ProfilesConfigureParams(
name = defaultProfile.name,
uiMeta = mapOf("hermes-bots-groups" to newSnapshot.toMap()).toJsonObject(),
),
)
}
}
} catch (_: Exception) {
@@ -575,11 +577,11 @@ class BotsViewModel(
return lines.joinToString("\n")
}
private fun wsClientConfigureBot(
private suspend fun wsClientConfigureBot(
name: String,
meta: BotRosterMeta,
soul: String? = null,
): kotlinx.coroutines.CompletableDeferred<Any?> {
) {
val metaMap =
buildMap<String, Any> {
meta.title?.let { put("title", it) }
@@ -599,16 +601,15 @@ class BotsViewModel(
}
}
val params =
buildMap<String, Any> {
put("name", name)
put("ui_meta", mapOf("hermes-bots" to metaMap))
soul?.let { put("soul", it) }
}
return HermesWsClient.request(
WsMethods.PROFILES_CONFIGURE,
params,
HermesWsClient.call(
RpcMethods.PROFILES_CONFIGURE,
ProfilesConfigureParams(
name = name,
uiMeta = mapOf("hermes-bots" to metaMap).toJsonObject(),
soul = soul,
),
)
}
private fun Map<String, Any?>.toJsonObject(): JsonObject = toJsonElement() as JsonObject
}
@@ -16,7 +16,12 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams
import com.m57.hermescontrol.data.ws.contract.ProfilesListParams
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionCreateParams
import com.m57.hermescontrol.data.ws.contract.SessionInterruptParams
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.chat.tool.ToolResultSummary
import kotlinx.coroutines.CompletableDeferred
@@ -30,6 +35,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import java.util.UUID
@@ -323,14 +330,8 @@ class GroupChatViewModel(
private suspend fun fetchProfiles(): List<ProfileInfo> {
try {
val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await()
val jsonElement =
when (rpcResult) {
is JsonElement -> rpcResult
null -> null
else -> rpcResult.toJsonElement()
}
if (jsonElement != null) {
val jsonElement = HermesWsClient.call(RpcMethods.PROFILES_LIST, ProfilesListParams)
if (jsonElement !is JsonNull) {
val resp = json.decodeFromJsonElement<ProfilesResponse>(jsonElement)
if (!resp.profiles.isNullOrEmpty()) {
return resp.profiles
@@ -530,14 +531,13 @@ class GroupChatViewModel(
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
HermesWsClient.call(
RpcMethods.PROFILES_CONFIGURE,
ProfilesConfigureParams(
name = defaultProfile.name,
uiMeta = uiMetaPayload.toJsonElement() as JsonObject,
),
)
} catch (e: Exception) {
Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}")
}
@@ -559,17 +559,24 @@ class GroupChatViewModel(
val title = "Group: $groupName"
try {
val createParams =
buildMap<String, Any> {
put("profile", bot.name)
put("title", title)
put("source", "desktop")
put("hidden", true)
}
val deferred = HermesWsClient.request(WsMethods.SESSION_CREATE, createParams)
val res = deferred.await()
val sessionInfo = extractSessionInfo(res)
if (sessionInfo != null) {
val res =
HermesWsClient.call(
method = RpcMethods.SESSION_CREATE,
params =
SessionCreateParams(
source = "desktop",
profile = bot.name,
title = title,
hidden = true,
),
)
val runtimeId = res.sessionId?.takeIf { it.isNotBlank() }
if (runtimeId != null) {
val sessionInfo =
MemberSession(
runtimeSessionId = runtimeId,
storedSessionId = res.storedSessionId,
)
memberSessions[bot.name] = sessionInfo
return sessionInfo
}
@@ -625,14 +632,13 @@ class GroupChatViewModel(
val newSnapshot = existingSnapshot.copy(version = 3, updatedAt = now, rooms = updatedRooms)
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
HermesWsClient.call(
RpcMethods.PROFILES_CONFIGURE,
ProfilesConfigureParams(
name = defaultProfile.name,
uiMeta = uiMetaPayload.toJsonElement() as JsonObject,
),
)
return true
} catch (e: Exception) {
@@ -701,11 +707,10 @@ class GroupChatViewModel(
// turn boundary can be proven for them. They stay uncorrelated
// (reply notifications from group turns are never auto-dismissed
// from REST hydration) instead of borrowing another turn's bound.
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeId, "text" to prompt),
).await()
HermesWsClient.call(
method = RpcMethods.PROMPT_SUBMIT,
params = PromptSubmitParams(sessionId = runtimeId, text = prompt),
)
} catch (submitErr: Exception) {
Log.w(
"GroupChatViewModel",
@@ -735,11 +740,10 @@ class GroupChatViewModel(
storedId?.let { inFlightTurns[it] = turnDeferred }
try {
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeId, "text" to prompt),
).await()
HermesWsClient.call(
method = RpcMethods.PROMPT_SUBMIT,
params = PromptSubmitParams(sessionId = runtimeId, text = prompt),
)
} catch (retryErr: Exception) {
Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}")
return null
@@ -901,8 +905,8 @@ class GroupChatViewModel(
for (sid in activeSessions.distinct()) {
try {
HermesWsClient.send(
WsMethods.SESSION_INTERRUPT,
mapOf("session_id" to sid),
RpcMethods.SESSION_INTERRUPT,
SessionInterruptParams(sid),
)
} catch (e: Exception) {
Log.w("GroupChatViewModel", "Failed to send interrupt for session $sid: ${e.message}")
@@ -1184,14 +1188,13 @@ class GroupChatViewModel(
val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap())
HermesWsClient
.request(
WsMethods.PROFILES_CONFIGURE,
mapOf(
"name" to defaultProfile.name,
"ui_meta" to uiMetaPayload,
),
).await()
HermesWsClient.call(
RpcMethods.PROFILES_CONFIGURE,
ProfilesConfigureParams(
name = defaultProfile.name,
uiMeta = uiMetaPayload.toJsonElement() as JsonObject,
),
)
} catch (e: Exception) {
Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}")
}
@@ -1202,19 +1205,4 @@ class GroupChatViewModel(
val clean = text.trim().lowercase()
return clean == "(pass)" || clean == "pass" || clean == "(pass)." || clean == "pass."
}
private fun extractSessionInfo(response: Any?): MemberSession? {
if (response is Map<*, *>) {
val runtimeId =
response["session_id"]?.toString()?.trim('\"', ' ')
?: (response["result"] as? Map<*, *>)?.get("session_id")?.toString()?.trim('\"', ' ')
val storedId =
response["stored_session_id"]?.toString()?.trim('\"', ' ')
?: (response["result"] as? Map<*, *>)?.get("stored_session_id")?.toString()?.trim('\"', ' ')
if (!runtimeId.isNullOrBlank()) {
return MemberSession(runtimeSessionId = runtimeId, storedSessionId = storedId)
}
}
return null
}
}
@@ -0,0 +1,44 @@
package com.m57.hermescontrol.ui.chat
/** Tracks a launched browser across pause/resume and screen disposal (issue #1321). */
internal class BrowserReturnTracker {
data class Returned(
val operationId: String?,
)
private var inFlight = false
private var departed = false
private var operationId: String? = null
fun start(operationId: String? = null) {
this.operationId = operationId
inFlight = true
departed = false
}
fun onPause() {
if (inFlight) departed = true
}
/** Only an actual departure followed by resume counts as returning from the browser. */
fun onResume(): Returned? {
if (!inFlight || !departed) return null
val returned = Returned(operationId)
cancel()
return returned
}
/** Drop the tracking after a failed launch; the lifecycle guard handles the failed lease. */
fun cancel() {
inFlight = false
departed = false
operationId = null
}
/** Non-configuration disposal must release an outstanding departed activity lease. */
fun abandon(): Boolean {
val outstanding = inFlight && departed
cancel()
return outstanding
}
}
@@ -2,6 +2,11 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.contract.ApprovalPendingParams
import com.m57.hermescontrol.data.ws.contract.ApprovalReceivedParams
import com.m57.hermescontrol.data.ws.contract.ApprovalRespondParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.TypedRpcSender
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -22,7 +27,7 @@ class ChatApprovalsDelegate(
private val ioDispatcher: CoroutineDispatcher,
private val uiState: MutableStateFlow<ChatUiState>,
private val runtimeSessionId: () -> String?,
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
private val rpc: TypedRpcSender,
private val trackRequest: (id: String, method: String) -> Unit,
private val addSystemMessage: (text: String) -> Unit,
private val respondToServerRequest: ((String, JsonElement) -> Unit)? = null,
@@ -99,13 +104,12 @@ class ChatApprovalsDelegate(
if (event.serverRequestId == null && requestId != null && sessionId != null) {
scope.launch(ioDispatcher) {
runCatching {
wsSend(
WsMethods.APPROVAL_RECEIVED,
mapOf(
"session_id" to sessionId,
"request_id" to requestId,
rpc.send(
RpcMethods.APPROVAL_RECEIVED,
ApprovalReceivedParams(
sessionId = sessionId,
requestId = requestId,
),
null,
)
}
}
@@ -115,9 +119,9 @@ class ChatApprovalsDelegate(
fun replayPendingApproval(sessionId: String) {
val targetSessionId = runtimeSessionId() ?: sessionId
scope.launch(ioDispatcher) {
wsSend(
WsMethods.APPROVAL_PENDING,
mapOf("session_id" to targetSessionId),
rpc.send(
RpcMethods.APPROVAL_PENDING,
ApprovalPendingParams(sessionId = targetSessionId),
) { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) }
}
}
@@ -190,16 +194,14 @@ class ChatApprovalsDelegate(
)
return@launch
}
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"choice" to choice,
"all" to false,
)
if (requestId != null) params["request_id"] = requestId
wsSend(
WsMethods.APPROVAL_RESPOND,
params,
rpc.send(
RpcMethods.APPROVAL_RESPOND,
ApprovalRespondParams(
sessionId = sessionId,
choice = choice,
all = false,
requestId = requestId,
),
) { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) }
// The queue can hold more pendings — surface the next one.
replayPendingApproval(sessionId)
Loaded 100 of 315 files, more files were not shown because too many files have changed in this diff. Show more