From f34292aa837cec88ebbfcb49eac5e13a414095af Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:20:44 +0300 Subject: [PATCH] feat(#1218): support backend-owned connector setup operations (#1229) --- .../hermescontrol/ui/chat/ChatScreenTest.kt | 4 + .../components/ConnectionSetupSheetTest.kt | 268 +++++++++++++++ .../data/model/ConnectionOperationModels.kt | 75 +++++ .../data/ws/ConnectionOperationParser.kt | 114 +++++++ .../m57/hermescontrol/data/ws/EventParser.kt | 13 + .../com/m57/hermescontrol/data/ws/WsEvent.kt | 9 + .../m57/hermescontrol/data/ws/WsMethods.kt | 3 + .../chat/ChatConnectionOperationDelegate.kt | 269 +++++++++++++++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 60 +++- .../hermescontrol/ui/chat/ChatViewModel.kt | 68 +++- .../ui/chat/ChatWsEventReducer.kt | 4 + .../chat/components/ConnectionSetupSheet.kt | 293 ++++++++++++++++ app/src/main/res/values-ar/strings.xml | 13 + app/src/main/res/values-ko/strings.xml | 13 + app/src/main/res/values-zh/strings.xml | 13 + app/src/main/res/values/strings.xml | 13 + .../ws/ConnectionOperationEventParserTest.kt | 83 +++++ .../data/ws/ConnectionOperationParserTest.kt | 169 ++++++++++ .../ChatConnectionOperationDelegateTest.kt | 314 ++++++++++++++++++ .../ui/chat/ChatViewModelTest.kt | 72 ++++ .../ui/chat/SlashCommandDispatchRpcTest.kt | 9 +- 21 files changed, 1872 insertions(+), 7 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt index 5f04473b..2b4b9731 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt @@ -60,6 +60,8 @@ 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.connectionOperationState } returns + MutableStateFlow(ConnectionOperationUiState()).asStateFlow() // ActionProgressDialog collects the controller's StateFlow — a relaxed // mock proxy would crash the cast inside collectAsStateWithLifecycle. // A real controller (never started here) stays invisible. @@ -107,6 +109,8 @@ 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.connectionOperationState } returns + MutableStateFlow(ConnectionOperationUiState()).asStateFlow() every { mockViewModel.actionProgress } returns ActionProgressController(scope = CoroutineScope(Dispatchers.Main)) diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt new file mode 100644 index 00000000..d8f4ab7b --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt @@ -0,0 +1,268 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.ConnectionEnvField +import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot +import com.m57.hermescontrol.data.model.ConnectionOperationTarget +import com.m57.hermescontrol.data.model.ConnectionTargetAction +import com.m57.hermescontrol.data.model.ConnectionTargetKind +import com.m57.hermescontrol.data.model.ConnectionTargetState +import com.m57.hermescontrol.ui.chat.ConnectionOperationUiState +import com.m57.hermescontrol.ui.chat.ConnectionPendingAction +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 ConnectionSetupSheetTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun fieldsKeepBackendOrder_defaultsAreSafe_andRequiredInputGatesConnect() { + var response: Triple, Boolean>? = null + val target = + target( + requiredEnv = + listOf( + env("REGION", required = false, defaultValue = "us-east"), + env( + "API_TOKEN", + required = true, + secret = true, + defaultValue = "issue1218-secret-canary", + ), + ), + ) + composeTestRule.setContent { + ConnectionSetupContent( + state = state(target), + onRespond = { name, values, approved -> response = Triple(name, values, approved) }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + val region = composeTestRule.onNodeWithTag("connection_env_REGION") + val token = composeTestRule.onNodeWithTag("connection_env_API_TOKEN") + region.assertIsDisplayed().assertTextContains("us-east") + token.assertIsDisplayed() + composeTestRule.onNodeWithText("issue1218-secret-canary").assertDoesNotExist() + assertTrue(region.fetchSemanticsNode().boundsInRoot.top < token.fetchSemanticsNode().boundsInRoot.top) + composeTestRule.onNodeWithTag("connection_setup_connect").assertIsNotEnabled() + + token.performTextInput("typed-secret") + composeTestRule.onNodeWithTag("connection_setup_connect").assertIsEnabled().performClick() + + assertEquals("github", response?.first) + assertEquals(mapOf("REGION" to "us-east", "API_TOKEN" to "typed-secret"), response?.second) + assertEquals(true, response?.third) + } + + @Test + fun pendingAction_disablesMutatingControls() { + var calls = 0 + val operation = operation(target()) + composeTestRule.setContent { + ConnectionSetupContent( + state = + ConnectionOperationUiState( + operation = operation, + pendingAction = ConnectionPendingAction.Respond("op-a", "github", true, 1L), + ), + onRespond = { _, _, _ -> calls++ }, + onContinue = { calls++ }, + onOpenBrowser = { _, _ -> calls++ }, + ) + } + + composeTestRule.onNodeWithTag("connection_setup_connect").assertIsNotEnabled() + composeTestRule.onNodeWithTag("connection_setup_skip").assertIsNotEnabled() + composeTestRule.onNodeWithTag("connection_setup_continue").assertIsNotEnabled() + composeTestRule.onNodeWithTag("connection_setup_pending").assertIsDisplayed() + assertEquals(0, calls) + } + + @Test + fun browserAction_usesTheActiveUnresolvedTarget() { + var opened: Pair? = null + val connected = target(name = "already-done", state = ConnectionTargetState.CONNECTED) + val waiting = + target( + name = "slack", + state = ConnectionTargetState.INITIATED, + connectUrl = "https://example.com/oauth?state=abc", + ) + composeTestRule.setContent { + ConnectionSetupContent( + state = ConnectionOperationUiState(operation = operation(connected, waiting)), + onRespond = { _, _, _ -> }, + onContinue = {}, + onOpenBrowser = { operationId, url -> opened = operationId to url }, + ) + } + + composeTestRule.onNodeWithText("Set up slack").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_open_browser").performClick() + assertEquals("op-a" to "https://example.com/oauth?state=abc", opened) + } + + @Test + fun unsupportedTarget_degradesToSafeSkipAndContinueActions() { + var skipped = false + var continued = false + composeTestRule.setContent { + ConnectionSetupContent( + state = state(target(state = ConnectionTargetState.UNKNOWN)), + onRespond = { _, _, approved -> skipped = !approved }, + onContinue = { continued = true }, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithText("This setup step is not supported by this app version.").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_skip").performClick() + composeTestRule.onNodeWithTag("connection_setup_continue").performClick() + assertTrue(skipped) + assertTrue(continued) + } + + @Test + fun authorizedWithoutTools_showsDiscoveryErrorAndContinueOnly() { + composeTestRule.setContent { + ConnectionSetupContent( + state = + state( + target( + state = ConnectionTargetState.CONNECTED, + discoveryError = "No compatible tools were discovered", + ), + ), + onRespond = { _, _, _ -> }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithText("Authorized; tools unavailable").assertIsDisplayed() + composeTestRule.onNodeWithText("No compatible tools were discovered").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist() + composeTestRule.onNodeWithTag("connection_setup_continue").assertIsDisplayed() + } + + @Test + fun discoveryError_doesNotHideALaterPendingTarget() { + composeTestRule.setContent { + ConnectionSetupContent( + state = + ConnectionOperationUiState( + operation = + operation( + target( + name = "github", + state = ConnectionTargetState.CONNECTED, + discoveryError = "No compatible tools were discovered", + ), + target(name = "slack"), + ), + ), + onRespond = { _, _, _ -> }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithText("Set up slack").assertIsDisplayed() + composeTestRule.onNodeWithText("Authorized; tools unavailable").assertDoesNotExist() + } + + @Test + fun resolvedTarget_canStillSettleTheOperation() { + composeTestRule.setContent { + ConnectionSetupContent( + state = + state( + target( + state = ConnectionTargetState.CONNECTED, + tools = listOf("search", "fetch"), + ), + ), + onRespond = { _, _, _ -> }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithText("This connection step is complete").assertIsDisplayed() + composeTestRule.onNodeWithText("Tools available: 2").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_continue").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist() + } + + private fun state(target: ConnectionOperationTarget): ConnectionOperationUiState = + ConnectionOperationUiState(operation = operation(target)) + + private fun operation(vararg targets: ConnectionOperationTarget): ConnectionOperationSnapshot = + ConnectionOperationSnapshot( + sessionId = "session-a", + opId = "op-a", + seq = 1L, + deadlineAt = 2_000_000_000.0, + timeoutSeconds = 300.0, + toolCallId = "tool-a", + settled = false, + settledBy = null, + targets = targets.toList(), + ) + + private fun target( + name: String = "github", + state: ConnectionTargetState = ConnectionTargetState.PENDING, + connectUrl: String? = null, + discoveryError: String? = null, + requiredEnv: List = emptyList(), + tools: List = emptyList(), + ): ConnectionOperationTarget = + ConnectionOperationTarget( + name = name, + kind = ConnectionTargetKind.CONNECTOR, + action = ConnectionTargetAction.AUTHORIZE, + state = state, + detail = null, + instructions = null, + discoveryError = discoveryError, + connectUrl = connectUrl, + connectionId = null, + attempt = null, + requiredEnv = requiredEnv, + tools = tools, + hint = null, + ) + + private fun env( + name: String, + required: Boolean, + secret: Boolean = false, + defaultValue: String? = null, + ): ConnectionEnvField = + ConnectionEnvField( + name = name, + required = required, + secret = secret, + defaultValue = defaultValue, + prompt = null, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt new file mode 100644 index 00000000..064ca0b9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt @@ -0,0 +1,75 @@ +package com.m57.hermescontrol.data.model + +import com.m57.hermescontrol.util.ConnectorUrlValidator + +/** Backend-owned connector setup operation snapshot. Secrets are intentionally absent. */ +data class ConnectionOperationSnapshot( + val sessionId: String?, + val opId: String, + val seq: Long, + val deadlineAt: Double, + val timeoutSeconds: Double?, + val toolCallId: String?, + val settled: Boolean, + val settledBy: String?, + val targets: List, +) + +data class ConnectionOperationTarget( + val name: String, + val kind: ConnectionTargetKind, + val action: ConnectionTargetAction, + val state: ConnectionTargetState, + val detail: String?, + val instructions: String?, + val discoveryError: String?, + val connectUrl: String?, + val connectionId: String?, + val attempt: String?, + val requiredEnv: List, + val tools: List, + val hint: String?, +) { + /** Safe validated browser URL. OAuth query parameters stay redacted from [toString]. */ + val safeConnectUrl: String? + get() = connectUrl?.takeIf(ConnectorUrlValidator::isValidHttpsUrl) + + override fun toString(): String = + "ConnectionOperationTarget(" + + "name=$name, kind=$kind, action=$action, state=$state, detail=$detail, " + + "instructions=$instructions, discoveryError=$discoveryError, " + + "connectUrl=${if (connectUrl == null) "null" else "[REDACTED]"}, " + + "connectionId=$connectionId, attempt=$attempt, requiredEnv=$requiredEnv, " + + "tools=$tools, hint=$hint)" +} + +data class ConnectionEnvField( + val name: String, + val required: Boolean, + val secret: Boolean, + val defaultValue: String?, + val prompt: String?, +) + +enum class ConnectionTargetKind { CONNECTOR, MCP, UNKNOWN } + +enum class ConnectionTargetAction { + AUTHORIZE, + CONNECT, + ENABLE, + INSTALL, + RECONNECT, + UNKNOWN, +} + +enum class ConnectionTargetState { + PENDING, + INITIATED, + CONNECTED, + SKIPPED, + FAILED, + EXPIRED, + UNAVAILABLE, + NOT_CONNECTED, + UNKNOWN, +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt new file mode 100644 index 00000000..b9748882 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt @@ -0,0 +1,114 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ConnectionEnvField +import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot +import com.m57.hermescontrol.data.model.ConnectionOperationTarget +import com.m57.hermescontrol.data.model.ConnectionTargetAction +import com.m57.hermescontrol.data.model.ConnectionTargetKind +import com.m57.hermescontrol.data.model.ConnectionTargetState + +/** Tolerant parser for backend-owned connector operation snapshots. */ +object ConnectionOperationParser { + fun parse( + payload: Map, + sessionId: String? = null, + ): ConnectionOperationSnapshot? { + val opId = (payload["op_id"] as? String)?.trim() ?: return null + val seq = payload["seq"].toLongOrNull() ?: return null + val deadlineAt = payload["deadline_at"].toDoubleOrNull() ?: return null + val targets = + (payload["targets"] as? List<*>) + ?.mapNotNull { parseTarget(it as? Map<*, *>) } + ?: return null + if (opId.isBlank() || seq < 0L || !deadlineAt.isFinite() || deadlineAt <= 0.0 || targets.isEmpty()) { + return null + } + return ConnectionOperationSnapshot( + sessionId = (sessionId ?: payload["session_id"] as? String)?.takeIf { it.isNotBlank() }, + opId = opId, + seq = seq, + deadlineAt = deadlineAt, + timeoutSeconds = + payload["timeout_seconds"] + .toDoubleOrNull() + ?.takeIf { it.isFinite() && it > 0.0 }, + toolCallId = payload["tool_call_id"] as? String, + settled = payload["settled"] as? Boolean ?: false, + settledBy = payload["settled_by"] as? String, + targets = targets, + ) + } + + private fun parseTarget(raw: Map<*, *>?): ConnectionOperationTarget? { + val map = raw ?: return null + val name = (map["name"] as? String)?.trim() ?: return null + if (name.isBlank()) return null + return ConnectionOperationTarget( + name = name, + kind = + enumValue( + map["kind"] as? String, + ConnectionTargetKind.values(), + ConnectionTargetKind.UNKNOWN, + ), + action = + enumValue( + map["action"] as? String, + ConnectionTargetAction.values(), + ConnectionTargetAction.UNKNOWN, + ), + state = + enumValue( + map["state"] as? String, + ConnectionTargetState.values(), + ConnectionTargetState.UNKNOWN, + ), + detail = map["detail"] as? String, + instructions = map["instructions"] as? String, + discoveryError = map["discovery_error"] as? String, + connectUrl = map["connect_url"] as? String, + connectionId = map["connection_id"] as? String, + attempt = map["attempt"] as? String, + requiredEnv = + (map["required_env"] as? List<*>) + ?.mapNotNull { parseEnv(it as? Map<*, *>) } + ?: emptyList(), + tools = (map["tools"] as? List<*>)?.filterIsInstance() ?: emptyList(), + hint = map["hint"] as? String, + ) + } + + private fun parseEnv(raw: Map<*, *>?): ConnectionEnvField? { + val map = raw ?: return null + val name = (map["name"] as? String)?.trim() ?: return null + if (name.isBlank()) return null + val secret = map["secret"] as? Boolean ?: false + return ConnectionEnvField( + name = name, + required = map["required"] as? Boolean ?: false, + secret = secret, + defaultValue = if (secret) null else map["default"] as? String, + prompt = map["prompt"] as? String, + ) + } + + private inline fun > enumValue( + raw: String?, + values: Array, + fallback: T, + ): T = values.firstOrNull { it.name.equals(raw, ignoreCase = true) } ?: fallback + + private fun Any?.toLongOrNull(): Long? = + when (this) { + is Number -> this.toLong().takeIf { this.toDouble() == it.toDouble() } + is String -> this.toLongOrNull() + else -> null + } + + private fun Any?.toDoubleOrNull(): Double? = + when (this) { + is Number -> this.toDouble() + is String -> this.toDoubleOrNull() + else -> null + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index b78d4824..99639aa4 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -75,6 +75,19 @@ object EventParser { val sessionId = params["session_id"] as? String ?: payload?.get("session_id") as? String return when (eventType) { + "connection.request", "connection.update" -> { + val snapshot = payload?.let { ConnectionOperationParser.parse(it, sessionId) } + // Operation payloads may carry credential defaults and OAuth URLs. + // Never retain the raw frame when a malformed operation degrades to Unknown. + if (snapshot == null) { + WsEvent.Unknown("") + } else if (eventType == "connection.request") { + WsEvent.ConnectionRequest(snapshot) + } else { + WsEvent.ConnectionUpdate(snapshot) + } + } + "gateway.ready" -> { WsEvent.GatewayReady(payload) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 131693c5..e5cf6c26 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.ws +import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot import com.m57.hermescontrol.ui.chat.TodoItem /** Parsed WebSocket events emitted by [HermesWsClient]. */ @@ -191,6 +192,14 @@ sealed class WsEvent { val sessionId: String? = null, ) : WsEvent() + data class ConnectionRequest( + val snapshot: ConnectionOperationSnapshot, + ) : WsEvent() + + data class ConnectionUpdate( + val snapshot: ConnectionOperationSnapshot, + ) : WsEvent() + // ── Status ─────────────────────────────────────────────────────────── data class StatusUpdate( diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 2d585be5..93e3c448 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -101,6 +101,9 @@ object WsMethods { // ── Connectors (issue #1091) ─────────────────────────────────────────── + const val CONNECTION_RESPOND = "connection.respond" + const val CONNECTORS_OPERATION_WAKE = "connectors.operation.wake" + /** List available third-party connectors and authorization state for a session. */ const val CONNECTORS_LIST = "connectors.list" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt new file mode 100644 index 00000000..7d03246f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt @@ -0,0 +1,269 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +sealed class ConnectionPendingAction { + abstract val opId: String + abstract val observedSeq: Long + + data class Respond( + override val opId: String, + val target: String, + val approved: Boolean, + override val observedSeq: Long, + ) : ConnectionPendingAction() + + data class Continue( + override val opId: String, + override val observedSeq: Long, + ) : ConnectionPendingAction() + + data class Wake( + override val opId: String, + override val observedSeq: Long, + ) : ConnectionPendingAction() +} + +data class ConnectionOperationError( + val message: String, + val retryable: Boolean = true, +) + +data class ConnectionOperationUiState( + val operation: ConnectionOperationSnapshot? = null, + val pendingAction: ConnectionPendingAction? = null, + val error: ConnectionOperationError? = null, +) + +fun interface ConnectionOperationRequester { + suspend fun request( + method: String, + params: Map, + ): Any? +} + +internal class ConnectionResumeCheckpoint( + internal val generation: Long, + internal val operationId: String?, + internal val seq: Long?, +) + +/** Session-scoped authoritative reducer for backend-owned connector operations. */ +class ChatConnectionOperationDelegate( + private val requester: ConnectionOperationRequester, +) { + private val _state = MutableStateFlow(ConnectionOperationUiState()) + val state: StateFlow = _state.asStateFlow() + private var sessionId: String? = null + private var generation = 0L + private val settled = LinkedHashSet() + + @Synchronized + fun reset(newSessionId: String? = null) { + generation++ + sessionId = newSessionId + settled.clear() + _state.value = ConnectionOperationUiState() + } + + /** Rebinds the same stored chat to its current runtime session after create/resume/reconnect. */ + @Synchronized + fun bindSession(newSessionId: String) { + if (newSessionId.isNotBlank()) sessionId = newSessionId + } + + @Synchronized + internal fun resumeCheckpoint(): ConnectionResumeCheckpoint = + ConnectionResumeCheckpoint( + generation = generation, + operationId = _state.value.operation?.opId, + seq = _state.value.operation?.seq, + ) + + fun acceptRequest(snapshot: ConnectionOperationSnapshot): Boolean = accept(snapshot, allowReplacement = true) + + fun acceptUpdate(snapshot: ConnectionOperationSnapshot): Boolean = accept(snapshot, allowReplacement = false) + + @Synchronized + private fun accept( + snapshot: ConnectionOperationSnapshot, + allowReplacement: Boolean, + ): Boolean { + if (!matchesSession(snapshot)) return false + if (settled.contains(snapshot.opId)) return false + val current = _state.value.operation + if (current != null && current.opId == snapshot.opId && snapshot.seq <= current.seq) return false + if (current != null && current.opId != snapshot.opId && !allowReplacement) return false + val pending = _state.value.pendingAction + val keepPending = + pending != null && + pending.opId == snapshot.opId && + snapshot.seq <= pending.observedSeq + _state.value = + ConnectionOperationUiState( + operation = snapshot, + pendingAction = pending.takeIf { keepPending }, + ) + if (snapshot.settled) markSettled(snapshot.opId) + return true + } + + /** Applies a resume snapshot only if it cannot regress a live event received after resume began. */ + @Synchronized + internal fun reconcileResume( + snapshot: ConnectionOperationSnapshot?, + checkpoint: ConnectionResumeCheckpoint, + ): Boolean { + if (checkpoint.generation != generation) return false + val current = _state.value.operation + val unchanged = current?.opId == checkpoint.operationId && current?.seq == checkpoint.seq + if (snapshot == null) { + if (unchanged) { + _state.value = ConnectionOperationUiState() + return true + } + return false + } + if (!unchanged && current?.opId != snapshot.opId) return false + return accept(snapshot, allowReplacement = unchanged) + } + + suspend fun respond( + target: String, + env: Map, + approved: Boolean, + ) { + val current = _state.value.operation ?: return + val targetSnapshot = current.targets.firstOrNull { it.name == target } ?: return + val safeEnv = + if (approved) { + val allowed = targetSnapshot.requiredEnv.mapTo(HashSet()) { it.name } + env.filterKeys(allowed::contains) + } else { + emptyMap() + } + val snapshot = + begin( + ConnectionPendingAction.Respond( + opId = current.opId, + target = target, + approved = approved, + observedSeq = current.seq, + ), + ) ?: return + val answer = + buildMap { + put("name", target) + put("status", if (approved) "approved" else "skipped") + if (safeEnv.isNotEmpty()) put("env", safeEnv) + } + dispatch( + method = WsMethods.CONNECTION_RESPOND, + params = + mapOf( + "session_id" to requireSessionId(), + "op_id" to snapshot.opId, + "result" to mapOf("targets" to listOf(answer)), + ), + ) + } + + suspend fun continueOperation() { + val snapshot = begin(ConnectionPendingAction.Continue(currentOp(), currentSeq())) ?: return + dispatch( + method = WsMethods.CONNECTION_RESPOND, + params = + mapOf( + "session_id" to requireSessionId(), + "op_id" to snapshot.opId, + "result" to mapOf("settled_by" to "continue"), + ), + ) + } + + suspend fun wake(expectedOpId: String) { + val current = _state.value.operation ?: return + if (current.opId != expectedOpId) return + val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return + dispatch( + method = WsMethods.CONNECTORS_OPERATION_WAKE, + params = mapOf("session_id" to requireSessionId(), "op_id" to snapshot.opId), + clearOnSuccess = true, + unknownOperationSettles = true, + ) + } + + private suspend fun dispatch( + method: String, + params: Map, + clearOnSuccess: Boolean = false, + unknownOperationSettles: Boolean = false, + ) { + val action = _state.value.pendingAction + val actionGeneration = generation + try { + requester.request(method, params) + // Success only acknowledges receipt. Keep the exactly-once lock until + // a newer authoritative snapshot advances the operation sequence. + if (clearOnSuccess && generation == actionGeneration && _state.value.pendingAction == action) { + _state.value = _state.value.copy(pendingAction = null) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (unknownOperationSettles && error is HermesWsClient.HermesRpcException && error.code == 4004) { + if (generation == actionGeneration && _state.value.pendingAction == action) { + action?.opId?.let(::markSettled) + } + } else if (generation == actionGeneration && _state.value.pendingAction == action) { + _state.value = + _state.value.copy( + pendingAction = null, + error = ConnectionOperationError("request_failed"), + ) + } + } + } + + @Synchronized + private fun begin(action: ConnectionPendingAction): ConnectionOperationSnapshot? { + val current = _state.value.operation ?: return null + if (sessionId.isNullOrBlank() || + _state.value.pendingAction != null || + current.opId != action.opId || + current.seq != action.observedSeq + ) { + return null + } + _state.value = _state.value.copy(pendingAction = action, error = null) + return current + } + + private fun matchesSession(snapshot: ConnectionOperationSnapshot): Boolean = + !sessionId.isNullOrBlank() && snapshot.sessionId == sessionId + + private fun requireSessionId(): String = checkNotNull(sessionId?.takeIf { it.isNotBlank() }) + + private fun currentOp(): String = + _state.value.operation + ?.opId + .orEmpty() + + private fun currentSeq(): Long = _state.value.operation?.seq ?: -1L + + private fun markSettled(opId: String) { + settled += opId + while (settled.size > MAX_SETTLED_IDS) settled.remove(settled.first()) + _state.value = ConnectionOperationUiState() + } + + private companion object { + const val MAX_SETTLED_IDS = 32 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 330203a2..ed645214 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -96,6 +96,7 @@ import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab import com.m57.hermescontrol.ui.chat.components.ChatTimelineNoPrefetchStrategy import com.m57.hermescontrol.ui.chat.components.ChatTimelineSheet +import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay @@ -158,6 +159,7 @@ fun ChatScreen( val connectorsViewModel: ChatConnectorsViewModel = viewModel() val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle() val actionProgressState by viewModel.actionProgress.state.collectAsStateWithLifecycle() + val connectionOperationState by viewModel.connectionOperationState.collectAsStateWithLifecycle() // Snapshot-backed search state — read directly so only the scopes that // read its fields recompose on search changes (bar, matched bubbles). val searchState = viewModel.searchState @@ -166,6 +168,8 @@ fun ChatScreen( val context = LocalContext.current var browserAuthInFlight by rememberSaveable { mutableStateOf(false) } var browserAuthDeparted by rememberSaveable { mutableStateOf(false) } + var connectionBrowserOperationId by remember { mutableStateOf(null) } + var connectionBrowserDeparted by remember { mutableStateOf(false) } DisposableEffect(lifecycleOwner) { val observer = @@ -175,15 +179,27 @@ fun ChatScreen( if (browserAuthInFlight) { browserAuthDeparted = true } + if (connectionBrowserOperationId != null) { + connectionBrowserDeparted = true + } connectorsViewModel.onPause() } Lifecycle.Event.ON_RESUME -> { connectorsViewModel.onResume() - if (browserAuthInFlight && browserAuthDeparted) { + val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted + val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted } + if (legacyBrowserReturned || operationId != null) { + ExternalActivityLifecycleGuard.externalActivityReturned() + } + if (legacyBrowserReturned) { browserAuthInFlight = false browserAuthDeparted = false - ExternalActivityLifecycleGuard.externalActivityReturned() + } + if (operationId != null) { + connectionBrowserOperationId = null + connectionBrowserDeparted = false + viewModel.wakeConnectionOperation(operationId) } } @@ -199,11 +215,18 @@ fun ChatScreen( .firstOrNull() val isChangingConfigs = activity?.isChangingConfigurations == true if (!isChangingConfigs) { + val externalActivityOutstanding = + (browserAuthInFlight && browserAuthDeparted) || + (connectionBrowserOperationId != null && connectionBrowserDeparted) + if (externalActivityOutstanding) { + ExternalActivityLifecycleGuard.externalActivityReturned() + } if (browserAuthInFlight && browserAuthDeparted) { browserAuthInFlight = false browserAuthDeparted = false - ExternalActivityLifecycleGuard.externalActivityReturned() } + connectionBrowserOperationId = null + connectionBrowserDeparted = false connectorsViewModel.onPause() connectorsViewModel.hide() } @@ -424,7 +447,8 @@ fun ChatScreen( state.btwState == null && viewingImage == null && !connectorsState.isVisible && - !timelineState.isOpen + !timelineState.isOpen && + connectionOperationState.operation == null // Lifecycle effects, permissions, session switching, auto-scroll, errors ChatLifecycleEffects( @@ -967,6 +991,34 @@ fun ChatScreen( ) } + if (connectionOperationState.operation != null) { + ConnectionSetupSheet( + state = connectionOperationState, + onRespond = viewModel::respondToConnection, + onContinue = viewModel::continueConnectionOperation, + onOpenBrowser = { operationId, url -> + if (ConnectorUrlValidator.isValidHttpsUrl(url)) { + try { + launchExternalActivity { + connectionBrowserOperationId = operationId + connectionBrowserDeparted = false + val intent = + Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + } catch (_: Exception) { + connectionBrowserOperationId = null + connectionBrowserDeparted = false + scrollScope.launch { snackbarHostState.showSnackbar(browserLaunchError) } + } + } + }, + onDismiss = viewModel::continueConnectionOperation, + ) + } + SessionIntegrationsSheet( uiState = connectorsState, sessionId = connectorsState.sessionId, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index ab556a0d..fef5ec71 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -25,6 +25,7 @@ import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.ws.CommandBlocklist import com.m57.hermescontrol.data.ws.CommandCatalog +import com.m57.hermescontrol.data.ws.ConnectionOperationParser import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent @@ -401,8 +402,14 @@ class ChatViewModel( ) : AndroidViewModel(application) { constructor(application: Application) : this(application, startCleanup = true) + private val connectionOperationDelegate = + ChatConnectionOperationDelegate { method, params -> + HermesWsClient.request(method, params).await() + } + // ── Internal state ─────────────────────────────────────────────────── private val _uiState = MutableStateFlow(ChatUiState()) + val connectionOperationState: StateFlow = connectionOperationDelegate.state private val _streamingState = MutableStateFlow(StreamingState()) @@ -413,6 +420,7 @@ class ChatViewModel( val generation: Long, val resumeSequence: Long = 0L, val sessionId: String? = null, + val connectionCheckpoint: ConnectionResumeCheckpoint? = null, ) private val sessionRequestById = ConcurrentHashMap() @@ -842,6 +850,14 @@ class ChatViewModel( return } + if (event is WsEvent.ConnectionRequest) { + if (!isCurrentSession(event.snapshot.sessionId)) return + connectionOperationDelegate.acceptRequest(event.snapshot) + } else if (event is WsEvent.ConnectionUpdate) { + if (!isCurrentSession(event.snapshot.sessionId)) return + connectionOperationDelegate.acceptUpdate(event.snapshot) + } + // Flush any throttled reasoning before a state transition so the // finalized/orphan message carries the latest reasoning text. when (event) { @@ -1272,7 +1288,18 @@ class ChatViewModel( } } + @Suppress("UNCHECKED_CAST") private fun handleSessionInfo(info: Map?) { + // session.resume/session.info carries the open operation so a mobile client + // that missed connection.request can reconstruct the backend-authoritative card. + val pendingConnection = info?.get("pending_connection") as? Map + if (pendingConnection != null) { + ConnectionOperationParser + .parse( + pendingConnection, + runtimeSessionId ?: _uiState.value.currentSessionId, + )?.let(connectionOperationDelegate::acceptRequest) + } // Session info pushed by backend when config changes // (model switch, reasoning level, etc.) if (info != null) { @@ -1385,6 +1412,7 @@ class ChatViewModel( } val storageId = resultMap["stored_session_id"] as? String ?: runtimeId runtimeSessionId = runtimeId + connectionOperationDelegate.bindSession(runtimeId) // The gateway persists the row lazily on the first prompt — // do not resume this key until presence is confirmed. sessionHasServerPresence = false @@ -1449,6 +1477,7 @@ class ChatViewModel( isLoading = false, ) runtimeSessionId = runtimeId + connectionOperationDelegate.bindSession(runtimeId) resumedGeneration = generation ActiveSessionHolder.set(runtimeId, storageId) sessionHasServerPresence = false @@ -1488,6 +1517,7 @@ class ChatViewModel( return } runtimeSessionId = runtimeId + connectionOperationDelegate.bindSession(runtimeId) // Resume succeeded — the gateway confirmed the DB row. sessionHasServerPresence = true val sessionId = @@ -1577,6 +1607,16 @@ class ChatViewModel( ) } val activeSessionId = runtimeSessionId ?: sessionId + // Reconnect replay: the backend-owned connector operation is + // authoritative and uses the same full snapshot as the live + // connection.request event. Feed it through the seq guard so + // a late resume response cannot regress a newer live update. + val pendingConnection = resultMap["pending_connection"] as? Map + val pendingSnapshot = + pendingConnection?.let { ConnectionOperationParser.parse(it, activeSessionId) } + request?.connectionCheckpoint?.let { checkpoint -> + connectionOperationDelegate.reconcileResume(pendingSnapshot, checkpoint) + } if (activeSessionId != null) approvalsDelegate.replayPendingApproval(activeSessionId) } @@ -3436,6 +3476,7 @@ class ChatViewModel( isLoading: Boolean, ): Long { val generation = ++sessionGeneration + connectionOperationDelegate.reset() cancelResumeRetry() contextUsageJob?.cancel() contextUsageJob = null @@ -3519,6 +3560,7 @@ class ChatViewModel( _uiState.update { it.copy(isSessionReady = false) } val requestSequence = ++resumeRequestSequence activeResumeRequestSequence = requestSequence + val connectionCheckpoint = connectionOperationDelegate.resumeCheckpoint() val profile = AuthManager.activeProfileId.value val params = mutableMapOf( @@ -3539,6 +3581,7 @@ class ChatViewModel( generation = generation, resumeSequence = requestSequence, sessionId = sessionId, + connectionCheckpoint = connectionCheckpoint, ) }, ) @@ -4432,8 +4475,15 @@ class ChatViewModel( generation: Long, resumeSequence: Long = 0L, sessionId: String? = null, + connectionCheckpoint: ConnectionResumeCheckpoint? = null, ) { - sessionRequestById[id] = SessionRequest(generation, resumeSequence, sessionId) + sessionRequestById[id] = + SessionRequest( + generation = generation, + resumeSequence = resumeSequence, + sessionId = sessionId, + connectionCheckpoint = connectionCheckpoint, + ) trackRequest(id, method) } @@ -4492,6 +4542,22 @@ class ChatViewModel( // ── Lifecycle ───────────────────────────────────────────────────────── + fun respondToConnection( + target: String, + env: Map, + approved: Boolean, + ) { + viewModelScope.launch { connectionOperationDelegate.respond(target, env, approved) } + } + + fun continueConnectionOperation() { + viewModelScope.launch { connectionOperationDelegate.continueOperation() } + } + + fun wakeConnectionOperation(operationId: String) { + viewModelScope.launch { connectionOperationDelegate.wake(operationId) } + } + override fun onCleared() { super.onCleared() subagentsDelegate.closeSubagentTranscript() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 53c1b1da..4f9088cd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -176,6 +176,10 @@ object ChatWsEventReducer { is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState) + is WsEvent.ConnectionRequest, + is WsEvent.ConnectionUpdate, + -> ReducerResult(state, streamingState) + is WsEvent.Unknown -> onUnknown(state, streamingState) // SessionInfo is a no-op in the original code diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt new file mode 100644 index 00000000..63b3b8e5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt @@ -0,0 +1,293 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.ConnectionOperationTarget +import com.m57.hermescontrol.data.model.ConnectionTargetState +import com.m57.hermescontrol.ui.chat.ConnectionOperationUiState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnectionSetupSheet( + state: ConnectionOperationUiState, + onRespond: (String, Map, Boolean) -> Unit, + onContinue: () -> Unit, + onOpenBrowser: (operationId: String, url: String) -> Unit, + onDismiss: () -> Unit, +) { + if (state.operation == null) return + ModalBottomSheet( + onDismissRequest = { + if (state.pendingAction == null) onDismiss() + }, + modifier = Modifier.testTag("connection_setup_sheet"), + ) { + ConnectionSetupContent( + state = state, + onRespond = onRespond, + onContinue = onContinue, + onOpenBrowser = onOpenBrowser, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +internal fun ConnectionSetupContent( + state: ConnectionOperationUiState, + onRespond: (String, Map, Boolean) -> Unit, + onContinue: () -> Unit, + onOpenBrowser: (operationId: String, url: String) -> Unit, + modifier: Modifier = Modifier, +) { + val operation = state.operation ?: return + val target = + operation.targets.firstOrNull { !isTerminalTarget(it) } + ?: operation.targets.firstOrNull { !it.discoveryError.isNullOrBlank() } + ?: operation.targets.lastOrNull() + ?: return + val targetIndex = operation.targets.indexOf(target) + val values = + remember(operation.opId, target.name) { + mutableStateMapOf().apply { + target.requiredEnv.forEach { field -> + this[field.name] = if (field.secret) "" else field.defaultValue.orEmpty() + } + } + } + LaunchedEffect(target.requiredEnv) { + val currentNames = target.requiredEnv.mapTo(HashSet()) { it.name } + values.keys + .toList() + .filterNot(currentNames::contains) + .forEach(values::remove) + target.requiredEnv.forEach { field -> + values.putIfAbsent(field.name, if (field.secret) "" else field.defaultValue.orEmpty()) + } + } + val missingRequired = target.requiredEnv.firstOrNull { it.required && values[it.name].orEmpty().isBlank() } + val pending = state.pendingAction != null + val safeUrl = target.safeConnectUrl + + Column( + modifier = + modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringResource(R.string.connection_setup_target_title, target.name), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.testTag("connection_setup_title"), + ) + Text( + text = + stringResource( + R.string.connection_setup_progress, + targetIndex + 1, + operation.targets.size, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + target.instructions?.takeIf { it.isNotBlank() }?.let { instructions -> + Text(instructions, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + when { + target.state == ConnectionTargetState.CONNECTED && !target.discoveryError.isNullOrBlank() -> { + Text( + text = stringResource(R.string.connection_setup_authorized_no_tools), + style = MaterialTheme.typography.titleMedium, + ) + Text(target.discoveryError, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + isTerminalTarget(target) -> { + Text( + text = stringResource(R.string.connection_setup_target_resolved), + style = MaterialTheme.typography.titleMedium, + ) + if (target.tools.isNotEmpty()) { + Text( + text = stringResource(R.string.connection_setup_tools_available, target.tools.size), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + target.detail?.takeIf { it.isNotBlank() }?.let { detail -> + Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + target.state == ConnectionTargetState.INITIATED && safeUrl != null -> { + target.detail?.takeIf { it.isNotBlank() }?.let { detail -> + Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text( + text = safeUrl, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.testTag("connection_setup_browser_url"), + ) + OutlinedButton( + onClick = { onOpenBrowser(operation.opId, safeUrl) }, + enabled = !pending, + modifier = + Modifier + .fillMaxWidth() + .testTag("connection_setup_open_browser"), + ) { + Text(stringResource(R.string.connection_setup_open_browser)) + } + } + + target.state == ConnectionTargetState.UNKNOWN || + (target.state == ConnectionTargetState.INITIATED && target.connectUrl != null) -> { + Text( + text = stringResource(R.string.connection_setup_unsupported), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + else -> { + target.requiredEnv.forEach { field -> + OutlinedTextField( + value = values[field.name].orEmpty(), + onValueChange = { values[field.name] = it }, + enabled = !pending, + label = { Text(field.prompt ?: field.name) }, + visualTransformation = + if (field.secret) PasswordVisualTransformation() else VisualTransformation.None, + singleLine = true, + modifier = + Modifier + .fillMaxWidth() + .testTag("connection_env_${field.name}"), + ) + } + target.detail?.takeIf { it.isNotBlank() }?.let { detail -> + Text( + text = detail, + color = + if (target.state == ConnectionTargetState.FAILED) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + missingRequired?.let { field -> + Text( + text = stringResource(R.string.connection_setup_required, field.prompt ?: field.name), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("connection_setup_validation"), + ) + } + Button( + onClick = { + val env = target.requiredEnv.associate { field -> field.name to values[field.name].orEmpty() } + onRespond(target.name, env, true) + }, + enabled = !pending && missingRequired == null && target.state != ConnectionTargetState.UNKNOWN, + modifier = + Modifier + .fillMaxWidth() + .testTag("connection_setup_connect"), + ) { + Text(stringResource(R.string.connection_setup_connect)) + } + } + } + + state.error?.let { + Text( + text = stringResource(R.string.connection_setup_request_failed), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("connection_setup_error"), + ) + } + if (pending) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.testTag("connection_setup_pending"), + ) { + CircularProgressIndicator() + Text(stringResource(R.string.connection_setup_pending)) + } + } + ConnectionSetupActions( + target = target, + pending = pending, + onSkip = { onRespond(target.name, emptyMap(), false) }, + onContinue = onContinue, + ) + } +} + +@Composable +private fun ConnectionSetupActions( + target: ConnectionOperationTarget, + pending: Boolean, + onSkip: () -> Unit, + onContinue: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (!isTerminalTarget(target)) { + OutlinedButton( + onClick = onSkip, + enabled = !pending, + modifier = + Modifier + .weight(1f) + .testTag("connection_setup_skip"), + ) { + Text(stringResource(R.string.connection_setup_skip)) + } + } + OutlinedButton( + onClick = onContinue, + enabled = !pending, + modifier = + Modifier + .weight(1f) + .testTag("connection_setup_continue"), + ) { + Text(stringResource(R.string.connection_setup_continue)) + } + } +} + +private fun isTerminalTarget(target: ConnectionOperationTarget): Boolean = + target.state == ConnectionTargetState.CONNECTED || + target.state == ConnectionTargetState.SKIPPED || + target.state == ConnectionTargetState.EXPIRED || + target.state == ConnectionTargetState.UNAVAILABLE diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 80f5cb59..69e49508 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1514,4 +1514,17 @@ تشغيل الفيديو جارٍ تجهيز الجلسة… الجلسة غير جاهزة — أعد المحاولة للمتابعة + إعداد %1$s + %1$d من %2$d + تم التفويض، لكن الأدوات غير متاحة + اكتملت خطوة الاتصال هذه + الأدوات المتاحة: %1$d + خطوة الإعداد هذه غير مدعومة في إصدار التطبيق الحالي. + اتصال + تخطي + متابعة + فتح في المتصفح + %1$s مطلوب + تعذر إرسال رد الإعداد. حاول مرة أخرى. + بانتظار Hermes… \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ee1048c0..adbf0164 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1197,4 +1197,17 @@ 비디오 재생 세션 준비 중… 세션이 준비되지 않았습니다. 다시 시도하세요 + %1$s 설정 + %2$d개 중 %1$d개 + 인증되었지만 도구를 사용할 수 없습니다 + 이 연결 단계가 완료되었습니다 + 사용 가능한 도구: %1$d + 현재 앱 버전에서는 이 설정 단계를 지원하지 않습니다. + 연결 + 건너뛰기 + 계속 + 브라우저에서 열기 + %1$s은(는) 필수입니다 + 설정 응답을 보낼 수 없습니다. 다시 시도하세요. + Hermes를 기다리는 중… \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 5cbae308..dc9370f7 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1467,4 +1467,17 @@ 播放视频 正在准备会话… 会话未就绪,请重试以继续 + 设置%1$s + 第%1$d项,共%2$d项 + 已授权,但工具不可用 + 此连接步骤已完成 + 可用工具:%1$d + 当前应用版本不支持此设置步骤。 + 连接 + 跳过 + 继续 + 在浏览器中打开 + %1$s为必填项 + 无法发送设置响应,请重试。 + 正在等待 Hermes… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4045ab92..2f8e0e58 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1707,4 +1707,17 @@ Play video Preparing session… Session not ready — retry to continue + Set up %1$s + %1$d of %2$d + Authorized; tools unavailable + This connection step is complete + Tools available: %1$d + This setup step is not supported by this app version. + Connect + Skip + Continue + Open in browser + %1$s is required + Couldn’t send the setup response. Try again. + Waiting for Hermes… diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt new file mode 100644 index 00000000..e1c7771e --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt @@ -0,0 +1,83 @@ +package com.m57.hermescontrol.data.ws + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectionOperationEventParserTest { + @Test + fun connectionRequest_usesEnvelopeSessionAndPayloadSequence() { + val event = + EventParser.parseParams( + mapOf( + "type" to "connection.request", + "session_id" to "runtime-session", + "seq" to 999, + "payload" to operationPayload(seq = 4L), + ), + ) + + assertTrue(event is WsEvent.ConnectionRequest) + val snapshot = (event as WsEvent.ConnectionRequest).snapshot + assertEquals("runtime-session", snapshot.sessionId) + assertEquals(4L, snapshot.seq) + } + + @Test + fun connectionUpdate_parsesFullSettledSnapshot() { + val event = + EventParser.parseParams( + mapOf( + "type" to "connection.update", + "session_id" to "runtime-session", + "payload" to + operationPayload(seq = 5L) + + mapOf( + "settled" to true, + "settled_by" to "all_resolved", + "future_field" to "ignored", + ), + ), + ) + + assertTrue(event is WsEvent.ConnectionUpdate) + val snapshot = (event as WsEvent.ConnectionUpdate).snapshot + assertTrue(snapshot.settled) + assertEquals("all_resolved", snapshot.settledBy) + } + + @Test + fun malformedOperation_degradesWithoutRetainingRawFrame() { + val event = + EventParser.parseParams( + params = + mapOf( + "type" to "connection.request", + "session_id" to "runtime-session", + "payload" to operationPayload(seq = 1L) - "op_id", + ), + rawJson = "issue1218-secret-canary", + ) + + assertTrue(event is WsEvent.Unknown) + assertEquals("", (event as WsEvent.Unknown).raw) + } + + private fun operationPayload(seq: Long): Map = + mapOf( + "op_id" to "op-1", + "seq" to seq, + "deadline_at" to 2_000_000_000.0, + "timeout_seconds" to 120.0, + "tool_call_id" to "tool-1", + "targets" to + listOf( + mapOf( + "name" to "github", + "kind" to "connector", + "action" to "authorize", + "state" to "pending", + ), + ), + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt new file mode 100644 index 00000000..132ac68a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt @@ -0,0 +1,169 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ConnectionTargetAction +import com.m57.hermescontrol.data.model.ConnectionTargetKind +import com.m57.hermescontrol.data.model.ConnectionTargetState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectionOperationParserTest { + @Test + fun parseCompleteSnapshot_preservesBackendOrderAndMetadata() { + val snapshot = ConnectionOperationParser.parse(completePayload(), "runtime-session") + + assertNotNull(snapshot) + snapshot!! + assertEquals("runtime-session", snapshot.sessionId) + assertEquals("op-1218", snapshot.opId) + assertEquals(7L, snapshot.seq) + assertEquals(2, snapshot.targets.size) + val target = snapshot.targets.first() + assertEquals("reports", target.name) + assertEquals(ConnectionTargetKind.MCP, target.kind) + assertEquals(ConnectionTargetAction.INSTALL, target.action) + assertEquals(ConnectionTargetState.PENDING, target.state) + assertEquals("install-2", target.attempt) + assertEquals("Use a workspace token", target.hint) + assertEquals(listOf("REGION", "API_TOKEN"), target.requiredEnv.map { it.name }) + assertEquals("us-east", target.requiredEnv[0].defaultValue) + assertNull(target.requiredEnv[1].defaultValue) + assertTrue(target.requiredEnv[1].secret) + assertEquals(listOf("search", "export"), target.tools) + } + + @Test + fun unknownFieldsAndEnumValues_degradeWithoutDroppingUsableTarget() { + val payload = + completePayload().toMutableMap().apply { + put("future_operation_field", mapOf("nested" to true)) + put( + "targets", + listOf( + targetPayload().toMutableMap().apply { + put("kind", "future-kind") + put("action", "future-action") + put("state", "future-state") + put("future_target_field", "ignored") + }, + ), + ) + } + + val target = ConnectionOperationParser.parse(payload, "runtime-session")!!.targets.single() + + assertEquals(ConnectionTargetKind.UNKNOWN, target.kind) + assertEquals(ConnectionTargetAction.UNKNOWN, target.action) + assertEquals(ConnectionTargetState.UNKNOWN, target.state) + } + + @Test + fun malformedIdentityOrDeadline_isRejected() { + assertNull(ConnectionOperationParser.parse(completePayload() - "op_id", "runtime-session")) + assertNull(ConnectionOperationParser.parse(completePayload() - "seq", "runtime-session")) + assertNull(ConnectionOperationParser.parse(completePayload() - "deadline_at", "runtime-session")) + assertNull(ConnectionOperationParser.parse(completePayload() + ("seq" to 1.5), "runtime-session")) + assertNull( + ConnectionOperationParser.parse(completePayload() + ("deadline_at" to Double.NaN), "runtime-session"), + ) + assertNull( + ConnectionOperationParser.parse(completePayload() + ("targets" to emptyList()), "runtime-session"), + ) + } + + @Test + fun secretDefaultsAndOAuthUrls_areNeverExposedByModels() { + val canary = "issue1218-secret-canary" + val payload = + completePayload() + + ( + "targets" to + listOf( + targetPayload() + + ( + "connect_url" to + "https://example.com/oauth?token=$canary" + ) + + ( + "required_env" to + listOf( + mapOf( + "name" to "API_TOKEN", + "required" to true, + "secret" to true, + "default" to canary, + ), + ) + ), + ) + ) + + val snapshot = ConnectionOperationParser.parse(payload, "runtime-session")!! + + assertNull( + snapshot.targets + .single() + .requiredEnv + .single() + .defaultValue, + ) + assertFalse(snapshot.toString().contains(canary)) + assertTrue(snapshot.toString().contains("[REDACTED]")) + } + + private fun completePayload(): Map = + mapOf( + "op_id" to "op-1218", + "seq" to 7L, + "deadline_at" to 2_000_000_000.0, + "timeout_seconds" to 300.0, + "tool_call_id" to "tool-1", + "settled" to false, + "targets" to + listOf( + targetPayload(), + mapOf( + "name" to "github", + "kind" to "connector", + "action" to "authorize", + "state" to "initiated", + ), + ), + ) + + private fun targetPayload(): Map = + mapOf( + "name" to "reports", + "kind" to "mcp", + "action" to "install", + "state" to "pending", + "detail" to "Credentials required", + "instructions" to "Enter the service credentials", + "discovery_error" to null, + "connect_url" to null, + "connection_id" to "vendor-account-1", + "attempt" to "install-2", + "hint" to "Use a workspace token", + "required_env" to + listOf( + mapOf( + "name" to "REGION", + "required" to false, + "secret" to false, + "default" to "us-east", + "prompt" to "Region", + ), + mapOf( + "name" to "API_TOKEN", + "required" to true, + "secret" to true, + "default" to "must-not-be-retained", + "prompt" to "API token", + ), + ), + "tools" to listOf("search", "export"), + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt new file mode 100644 index 00000000..107e8086 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt @@ -0,0 +1,314 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.ConnectionEnvField +import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot +import com.m57.hermescontrol.data.model.ConnectionOperationTarget +import com.m57.hermescontrol.data.model.ConnectionTargetAction +import com.m57.hermescontrol.data.model.ConnectionTargetKind +import com.m57.hermescontrol.data.model.ConnectionTargetState +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatConnectionOperationDelegateTest { + @Test + fun requestAndUpdate_areStrictlyOrderedAndCorrelated() { + val delegate = delegate() + delegate.reset("session-a") + + assertTrue(delegate.acceptRequest(snapshot(opId = "op-a", seq = 1L))) + assertFalse(delegate.acceptUpdate(snapshot(opId = "op-a", seq = 1L))) + assertFalse(delegate.acceptUpdate(snapshot(opId = "op-old", seq = 99L))) + assertEquals( + "op-a", + delegate.state.value.operation + ?.opId, + ) + assertTrue(delegate.acceptUpdate(snapshot(opId = "op-a", seq = 2L))) + assertEquals( + 2L, + delegate.state.value.operation + ?.seq, + ) + + assertTrue(delegate.acceptRequest(snapshot(opId = "op-b", seq = 1L))) + assertFalse(delegate.acceptUpdate(snapshot(opId = "op-a", seq = 3L))) + assertEquals( + "op-b", + delegate.state.value.operation + ?.opId, + ) + } + + @Test + fun wrongSessionAndSettledOperation_areNeverReopened() { + val delegate = delegate() + delegate.reset("session-a") + + assertFalse(delegate.acceptRequest(snapshot(sessionId = "session-b"))) + assertTrue(delegate.acceptRequest(snapshot(opId = "op-a", seq = 1L))) + assertTrue(delegate.acceptUpdate(snapshot(opId = "op-a", seq = 2L, settled = true))) + assertNull(delegate.state.value.operation) + assertFalse(delegate.acceptRequest(snapshot(opId = "op-a", seq = 3L))) + } + + @Test + fun resumeReconciliation_cannotRegressOrClearNewerLiveRequest() { + val delegate = delegate() + delegate.reset("session-a") + delegate.acceptRequest(snapshot(opId = "op-a", seq = 2L)) + val checkpoint = delegate.resumeCheckpoint() + + delegate.acceptRequest(snapshot(opId = "op-b", seq = 1L)) + + assertFalse(delegate.reconcileResume(null, checkpoint)) + assertFalse(delegate.reconcileResume(snapshot(opId = "op-a", seq = 2L), checkpoint)) + assertEquals( + "op-b", + delegate.state.value.operation + ?.opId, + ) + } + + @Test + fun absentResumeSnapshot_clearsOnlyUnchangedOperation() { + val delegate = delegate() + delegate.reset("session-a") + delegate.acceptRequest(snapshot(opId = "op-a", seq = 2L)) + val checkpoint = delegate.resumeCheckpoint() + + assertTrue(delegate.reconcileResume(null, checkpoint)) + assertNull(delegate.state.value.operation) + } + + @Test + fun approvePayload_isExactAndSecretsNeverEnterUiState() = + runTest { + val requester = RecordingRequester() + val delegate = delegate(requester) + delegate.reset("session-a") + delegate.acceptRequest(snapshot(requiredEnv = listOf(envField("TOKEN", secret = true)))) + val canary = "issue1218-secret-canary" + + delegate.respond( + target = "github", + env = mapOf("TOKEN" to canary, "UNDECLARED" to "drop-me"), + approved = true, + ) + + assertEquals(1, requester.calls.size) + val call = requester.calls.single() + assertEquals(WsMethods.CONNECTION_RESPOND, call.first) + assertEquals("session-a", call.second["session_id"]) + assertEquals("op-a", call.second["op_id"]) + @Suppress("UNCHECKED_CAST") + val result = call.second["result"] as Map + + @Suppress("UNCHECKED_CAST") + val answer = (result["targets"] as List>).single() + assertEquals("github", answer["name"]) + assertEquals("approved", answer["status"]) + assertEquals(mapOf("TOKEN" to canary), answer["env"]) + assertFalse( + delegate.state.value + .toString() + .contains(canary), + ) + assertTrue(delegate.state.value.pendingAction is ConnectionPendingAction.Respond) + } + + @Test + fun rpcSuccessKeepsExactlyOnceLock_untilNewerSnapshotArrives() = + runTest { + val requester = RecordingRequester() + val delegate = delegate(requester) + delegate.reset("session-a") + delegate.acceptRequest(snapshot(seq = 1L)) + + delegate.respond("github", emptyMap(), approved = false) + delegate.respond("github", emptyMap(), approved = false) + + assertEquals(1, requester.calls.size) + assertEquals( + ConnectionTargetState.PENDING, + delegate.state.value.operation + ?.targets + ?.single() + ?.state, + ) + assertTrue(delegate.state.value.pendingAction is ConnectionPendingAction.Respond) + + delegate.acceptUpdate(snapshot(seq = 2L, state = ConnectionTargetState.PENDING)) + assertNull(delegate.state.value.pendingAction) + delegate.respond("github", emptyMap(), approved = false) + assertEquals(2, requester.calls.size) + } + + @Test + fun skipAndContinuePayloads_matchBackendContract() = + runTest { + val skipRequester = RecordingRequester() + val skipDelegate = delegate(skipRequester) + skipDelegate.reset("session-a") + skipDelegate.acceptRequest(snapshot()) + skipDelegate.respond("github", mapOf("TOKEN" to "must-drop"), approved = false) + + @Suppress("UNCHECKED_CAST") + val skipResult = skipRequester.calls.single().second["result"] as Map + + @Suppress("UNCHECKED_CAST") + val skipAnswer = (skipResult["targets"] as List>).single() + assertEquals(mapOf("name" to "github", "status" to "skipped"), skipAnswer) + + val continueRequester = RecordingRequester() + val continueDelegate = delegate(continueRequester) + continueDelegate.reset("session-a") + continueDelegate.acceptRequest(snapshot()) + continueDelegate.continueOperation() + + assertEquals( + mapOf("settled_by" to "continue"), + continueRequester.calls.single().second["result"], + ) + } + + @Test + fun rpcFailureUnlocksWithSanitizedError() = + runTest { + val requester = RecordingRequester(failure = IllegalStateException("secret backend detail")) + val delegate = delegate(requester) + delegate.reset("session-a") + delegate.acceptRequest(snapshot()) + + delegate.respond("github", emptyMap(), approved = false) + + assertNull(delegate.state.value.pendingAction) + assertEquals( + "request_failed", + delegate.state.value.error + ?.message, + ) + assertFalse( + delegate.state.value + .toString() + .contains("secret backend detail"), + ) + } + + @Test + fun lateOldGenerationFailure_cannotMutateResetState() = + runTest { + val gate = CompletableDeferred() + val requester = + ConnectionOperationRequester { _, _ -> + gate.await() + throw IllegalStateException("old failure") + } + val delegate = ChatConnectionOperationDelegate(requester) + delegate.reset("session-a") + delegate.acceptRequest(snapshot()) + + val action = launch { delegate.respond("github", emptyMap(), approved = false) } + runCurrent() + delegate.reset("session-b") + gate.complete(Unit) + action.join() + + assertNull(delegate.state.value.operation) + assertNull(delegate.state.value.error) + } + + @Test + fun wakeUnknownOperation_isHarmlessSettledRace() = + runTest { + val requester = + RecordingRequester( + failure = HermesWsClient.HermesRpcException("gone", code = 4004), + ) + val delegate = delegate(requester) + delegate.reset("session-a") + delegate.acceptRequest(snapshot()) + + delegate.wake("op-a") + + assertNull(delegate.state.value.operation) + assertNull(delegate.state.value.error) + } + + private fun delegate(requester: RecordingRequester = RecordingRequester()): ChatConnectionOperationDelegate = + ChatConnectionOperationDelegate(requester) + + private fun snapshot( + sessionId: String = "session-a", + opId: String = "op-a", + seq: Long = 1L, + state: ConnectionTargetState = ConnectionTargetState.PENDING, + settled: Boolean = false, + requiredEnv: List = emptyList(), + ): ConnectionOperationSnapshot = + ConnectionOperationSnapshot( + sessionId = sessionId, + opId = opId, + seq = seq, + deadlineAt = 2_000_000_000.0, + timeoutSeconds = 300.0, + toolCallId = "tool-a", + settled = settled, + settledBy = if (settled) "all_resolved" else null, + targets = + listOf( + ConnectionOperationTarget( + name = "github", + kind = ConnectionTargetKind.CONNECTOR, + action = ConnectionTargetAction.AUTHORIZE, + state = state, + detail = null, + instructions = null, + discoveryError = null, + connectUrl = null, + connectionId = null, + attempt = null, + requiredEnv = requiredEnv, + tools = emptyList(), + hint = null, + ), + ), + ) + + private fun envField( + name: String, + secret: Boolean, + ): ConnectionEnvField = + ConnectionEnvField( + name = name, + required = true, + secret = secret, + defaultValue = null, + prompt = null, + ) + + private class RecordingRequester( + private val failure: Throwable? = null, + ) : ConnectionOperationRequester { + val calls = mutableListOf>>() + + override suspend fun request( + method: String, + params: Map, + ): Any? { + calls += method to params + failure?.let { throw it } + return Unit + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index a624c929..e1faf600 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -24,6 +24,7 @@ import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.data.remote.GatewayFileResult import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator +import com.m57.hermescontrol.data.ws.ConnectionOperationParser import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.JsonRpcError @@ -411,6 +412,77 @@ class ChatViewModelTest { return Pair(viewModel, "session-123") } + @Test + fun connectionOperation_liveEventRoutesAndSessionResetClearsIt() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + val snapshot = connectionOperationSnapshot(sessionId = sessionId) + + mockEventsFlow.emit(WsEvent.ConnectionRequest(snapshot)) + advanceUntilIdle() + assertEquals( + "op-1218", + viewModel.connectionOperationState.value.operation + ?.opId, + ) + + viewModel.createNewSession() + advanceUntilIdle() + assertNull(viewModel.connectionOperationState.value.operation) + + mockEventsFlow.emit(WsEvent.ConnectionUpdate(snapshot.copy(seq = 2L))) + advanceUntilIdle() + assertNull(viewModel.connectionOperationState.value.operation) + } + + @Test + fun connectionOperation_sessionResumeRestoresPendingSnapshot() = + runTest { + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.switchSession("stored-session") + advanceUntilIdle() + val resumeRequestId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second + + mockEventsFlow.emit( + WsEvent.RpcResult( + resumeRequestId, + mapOf( + "session_id" to "runtime-session", + "pending_connection" to connectionOperationPayload(), + ), + ), + ) + advanceUntilIdle() + + val operation = viewModel.connectionOperationState.value.operation + assertEquals("runtime-session", operation?.sessionId) + assertEquals("op-1218", operation?.opId) + } + + private fun connectionOperationSnapshot( + sessionId: String, + seq: Long = 1L, + ) = checkNotNull(ConnectionOperationParser.parse(connectionOperationPayload(seq), sessionId)) + + private fun connectionOperationPayload(seq: Long = 1L): Map = + mapOf( + "op_id" to "op-1218", + "seq" to seq, + "deadline_at" to 2_000_000_000.0, + "timeout_seconds" to 300.0, + "tool_call_id" to "tool-1218", + "targets" to + listOf( + mapOf( + "name" to "github", + "kind" to "connector", + "action" to "authorize", + "state" to "pending", + ), + ), + ) + // ── Slash command tests ────────────────────────────────────────────────── @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index f64050d3..3b0c064b 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -53,11 +53,13 @@ class SlashCommandDispatchRpcTest { private lateinit var app: Application private lateinit var fakeRepo: FakeChatPersistenceRepository private var reqCount = 0 + private var sessionCreateRequestId: String? = null @Before fun setUp() { Dispatchers.setMain(testDispatcher) reqCount = 0 + sessionCreateRequestId = null mockkStatic(android.util.Log::class) every { android.util.Log.d(any(), any()) } returns 0 @@ -114,6 +116,9 @@ class SlashCommandDispatchRpcTest { every { HermesWsClient.send(any(), any(), any()) } answers { reqCount++ val id = "req-id-$reqCount" + if (arg(0) == WsMethods.SESSION_CREATE) { + sessionCreateRequestId = id + } arg<((String) -> Unit)?>(2)?.invoke(id) id } @@ -152,8 +157,8 @@ class SlashCommandDispatchRpcTest { mockConnectionStatus.value = ConnectionStatus.CONNECTED mockEventsFlow.emit(WsEvent.GatewayReady(null)) advanceUntilIdle() - // req-id-3 = session.create (after loadSessions + fetchCommandCatalog) - mockEventsFlow.emit(WsEvent.RpcResult("req-id-3", mapOf("session_id" to "session-xyz"))) + val createRequestId = requireNotNull(sessionCreateRequestId) { "session.create was not sent" } + mockEventsFlow.emit(WsEvent.RpcResult(createRequestId, mapOf("session_id" to "session-xyz"))) advanceUntilIdle() return Pair(vm, "session-xyz") }