21 files changed
+1872
-7
No files matched your search
@@ -60,6 +60,8 @@ class ChatScreenTest {
|
|||||||
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
||||||
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
||||||
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
|
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
|
||||||
|
every { mockViewModel.connectionOperationState } returns
|
||||||
|
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
|
||||||
// ActionProgressDialog collects the controller's StateFlow — a relaxed
|
// ActionProgressDialog collects the controller's StateFlow — a relaxed
|
||||||
// mock proxy would crash the cast inside collectAsStateWithLifecycle.
|
// mock proxy would crash the cast inside collectAsStateWithLifecycle.
|
||||||
// A real controller (never started here) stays invisible.
|
// A real controller (never started here) stays invisible.
|
||||||
@@ -107,6 +109,8 @@ class ChatScreenTest {
|
|||||||
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
||||||
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
||||||
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
|
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
|
||||||
|
every { mockViewModel.connectionOperationState } returns
|
||||||
|
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
|
||||||
every { mockViewModel.actionProgress } returns
|
every { mockViewModel.actionProgress } returns
|
||||||
ActionProgressController(scope = CoroutineScope(Dispatchers.Main))
|
ActionProgressController(scope = CoroutineScope(Dispatchers.Main))
|
||||||
|
|
||||||
|
|||||||
+268
@@ -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<String, Map<String, String>, 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<String, String>? = 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<ConnectionEnvField> = emptyList(),
|
||||||
|
tools: List<String> = 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<ConnectionOperationTarget>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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<ConnectionEnvField>,
|
||||||
|
val tools: List<String>,
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -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<String, Any?>,
|
||||||
|
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<String>() ?: 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 <reified T : Enum<T>> enumValue(
|
||||||
|
raw: String?,
|
||||||
|
values: Array<T>,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,19 @@ object EventParser {
|
|||||||
val sessionId = params["session_id"] as? String ?: payload?.get("session_id") as? String
|
val sessionId = params["session_id"] as? String ?: payload?.get("session_id") as? String
|
||||||
|
|
||||||
return when (eventType) {
|
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" -> {
|
"gateway.ready" -> {
|
||||||
WsEvent.GatewayReady(payload)
|
WsEvent.GatewayReady(payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.data.ws
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
|
||||||
import com.m57.hermescontrol.ui.chat.TodoItem
|
import com.m57.hermescontrol.ui.chat.TodoItem
|
||||||
|
|
||||||
/** Parsed WebSocket events emitted by [HermesWsClient]. */
|
/** Parsed WebSocket events emitted by [HermesWsClient]. */
|
||||||
@@ -191,6 +192,14 @@ sealed class WsEvent {
|
|||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
|
data class ConnectionRequest(
|
||||||
|
val snapshot: ConnectionOperationSnapshot,
|
||||||
|
) : WsEvent()
|
||||||
|
|
||||||
|
data class ConnectionUpdate(
|
||||||
|
val snapshot: ConnectionOperationSnapshot,
|
||||||
|
) : WsEvent()
|
||||||
|
|
||||||
// ── Status ───────────────────────────────────────────────────────────
|
// ── Status ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class StatusUpdate(
|
data class StatusUpdate(
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ object WsMethods {
|
|||||||
|
|
||||||
// ── Connectors (issue #1091) ───────────────────────────────────────────
|
// ── 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. */
|
/** List available third-party connectors and authorization state for a session. */
|
||||||
const val CONNECTORS_LIST = "connectors.list"
|
const val CONNECTORS_LIST = "connectors.list"
|
||||||
|
|
||||||
|
|||||||
@@ -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<String, Any>,
|
||||||
|
): 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<ConnectionOperationUiState> = _state.asStateFlow()
|
||||||
|
private var sessionId: String? = null
|
||||||
|
private var generation = 0L
|
||||||
|
private val settled = LinkedHashSet<String>()
|
||||||
|
|
||||||
|
@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<String, String>,
|
||||||
|
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<String, Any> {
|
||||||
|
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<String, Any>,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.ChatScrollToBottomFab
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatTimelineNoPrefetchStrategy
|
import com.m57.hermescontrol.ui.chat.components.ChatTimelineNoPrefetchStrategy
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatTimelineSheet
|
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.ContextDetailSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
||||||
@@ -158,6 +159,7 @@ fun ChatScreen(
|
|||||||
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
|
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
|
||||||
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
|
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val actionProgressState by viewModel.actionProgress.state.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
|
// Snapshot-backed search state — read directly so only the scopes that
|
||||||
// read its fields recompose on search changes (bar, matched bubbles).
|
// read its fields recompose on search changes (bar, matched bubbles).
|
||||||
val searchState = viewModel.searchState
|
val searchState = viewModel.searchState
|
||||||
@@ -166,6 +168,8 @@ fun ChatScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
|
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
|
||||||
var browserAuthDeparted by rememberSaveable { mutableStateOf(false) }
|
var browserAuthDeparted by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var connectionBrowserOperationId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var connectionBrowserDeparted by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
val observer =
|
val observer =
|
||||||
@@ -175,15 +179,27 @@ fun ChatScreen(
|
|||||||
if (browserAuthInFlight) {
|
if (browserAuthInFlight) {
|
||||||
browserAuthDeparted = true
|
browserAuthDeparted = true
|
||||||
}
|
}
|
||||||
|
if (connectionBrowserOperationId != null) {
|
||||||
|
connectionBrowserDeparted = true
|
||||||
|
}
|
||||||
connectorsViewModel.onPause()
|
connectorsViewModel.onPause()
|
||||||
}
|
}
|
||||||
|
|
||||||
Lifecycle.Event.ON_RESUME -> {
|
Lifecycle.Event.ON_RESUME -> {
|
||||||
connectorsViewModel.onResume()
|
connectorsViewModel.onResume()
|
||||||
if (browserAuthInFlight && browserAuthDeparted) {
|
val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted
|
||||||
|
val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted }
|
||||||
|
if (legacyBrowserReturned || operationId != null) {
|
||||||
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
|
}
|
||||||
|
if (legacyBrowserReturned) {
|
||||||
browserAuthInFlight = false
|
browserAuthInFlight = false
|
||||||
browserAuthDeparted = false
|
browserAuthDeparted = false
|
||||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
}
|
||||||
|
if (operationId != null) {
|
||||||
|
connectionBrowserOperationId = null
|
||||||
|
connectionBrowserDeparted = false
|
||||||
|
viewModel.wakeConnectionOperation(operationId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,11 +215,18 @@ fun ChatScreen(
|
|||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
val isChangingConfigs = activity?.isChangingConfigurations == true
|
val isChangingConfigs = activity?.isChangingConfigurations == true
|
||||||
if (!isChangingConfigs) {
|
if (!isChangingConfigs) {
|
||||||
|
val externalActivityOutstanding =
|
||||||
|
(browserAuthInFlight && browserAuthDeparted) ||
|
||||||
|
(connectionBrowserOperationId != null && connectionBrowserDeparted)
|
||||||
|
if (externalActivityOutstanding) {
|
||||||
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
|
}
|
||||||
if (browserAuthInFlight && browserAuthDeparted) {
|
if (browserAuthInFlight && browserAuthDeparted) {
|
||||||
browserAuthInFlight = false
|
browserAuthInFlight = false
|
||||||
browserAuthDeparted = false
|
browserAuthDeparted = false
|
||||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
|
||||||
}
|
}
|
||||||
|
connectionBrowserOperationId = null
|
||||||
|
connectionBrowserDeparted = false
|
||||||
connectorsViewModel.onPause()
|
connectorsViewModel.onPause()
|
||||||
connectorsViewModel.hide()
|
connectorsViewModel.hide()
|
||||||
}
|
}
|
||||||
@@ -424,7 +447,8 @@ fun ChatScreen(
|
|||||||
state.btwState == null &&
|
state.btwState == null &&
|
||||||
viewingImage == null &&
|
viewingImage == null &&
|
||||||
!connectorsState.isVisible &&
|
!connectorsState.isVisible &&
|
||||||
!timelineState.isOpen
|
!timelineState.isOpen &&
|
||||||
|
connectionOperationState.operation == null
|
||||||
|
|
||||||
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
||||||
ChatLifecycleEffects(
|
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(
|
SessionIntegrationsSheet(
|
||||||
uiState = connectorsState,
|
uiState = connectorsState,
|
||||||
sessionId = connectorsState.sessionId,
|
sessionId = connectorsState.sessionId,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
|||||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||||
import com.m57.hermescontrol.data.ws.CommandBlocklist
|
import com.m57.hermescontrol.data.ws.CommandBlocklist
|
||||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
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.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
@@ -401,8 +402,14 @@ class ChatViewModel(
|
|||||||
) : AndroidViewModel(application) {
|
) : AndroidViewModel(application) {
|
||||||
constructor(application: Application) : this(application, startCleanup = true)
|
constructor(application: Application) : this(application, startCleanup = true)
|
||||||
|
|
||||||
|
private val connectionOperationDelegate =
|
||||||
|
ChatConnectionOperationDelegate { method, params ->
|
||||||
|
HermesWsClient.request(method, params).await()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal state ───────────────────────────────────────────────────
|
// ── Internal state ───────────────────────────────────────────────────
|
||||||
private val _uiState = MutableStateFlow(ChatUiState())
|
private val _uiState = MutableStateFlow(ChatUiState())
|
||||||
|
val connectionOperationState: StateFlow<ConnectionOperationUiState> = connectionOperationDelegate.state
|
||||||
|
|
||||||
private val _streamingState = MutableStateFlow(StreamingState())
|
private val _streamingState = MutableStateFlow(StreamingState())
|
||||||
|
|
||||||
@@ -413,6 +420,7 @@ class ChatViewModel(
|
|||||||
val generation: Long,
|
val generation: Long,
|
||||||
val resumeSequence: Long = 0L,
|
val resumeSequence: Long = 0L,
|
||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
|
val connectionCheckpoint: ConnectionResumeCheckpoint? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
||||||
@@ -842,6 +850,14 @@ class ChatViewModel(
|
|||||||
return
|
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
|
// Flush any throttled reasoning before a state transition so the
|
||||||
// finalized/orphan message carries the latest reasoning text.
|
// finalized/orphan message carries the latest reasoning text.
|
||||||
when (event) {
|
when (event) {
|
||||||
@@ -1272,7 +1288,18 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
private fun handleSessionInfo(info: Map<String, Any?>?) {
|
private fun handleSessionInfo(info: Map<String, Any?>?) {
|
||||||
|
// 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<String, Any?>
|
||||||
|
if (pendingConnection != null) {
|
||||||
|
ConnectionOperationParser
|
||||||
|
.parse(
|
||||||
|
pendingConnection,
|
||||||
|
runtimeSessionId ?: _uiState.value.currentSessionId,
|
||||||
|
)?.let(connectionOperationDelegate::acceptRequest)
|
||||||
|
}
|
||||||
// Session info pushed by backend when config changes
|
// Session info pushed by backend when config changes
|
||||||
// (model switch, reasoning level, etc.)
|
// (model switch, reasoning level, etc.)
|
||||||
if (info != null) {
|
if (info != null) {
|
||||||
@@ -1385,6 +1412,7 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
|
val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
|
||||||
runtimeSessionId = runtimeId
|
runtimeSessionId = runtimeId
|
||||||
|
connectionOperationDelegate.bindSession(runtimeId)
|
||||||
// The gateway persists the row lazily on the first prompt —
|
// The gateway persists the row lazily on the first prompt —
|
||||||
// do not resume this key until presence is confirmed.
|
// do not resume this key until presence is confirmed.
|
||||||
sessionHasServerPresence = false
|
sessionHasServerPresence = false
|
||||||
@@ -1449,6 +1477,7 @@ class ChatViewModel(
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
)
|
)
|
||||||
runtimeSessionId = runtimeId
|
runtimeSessionId = runtimeId
|
||||||
|
connectionOperationDelegate.bindSession(runtimeId)
|
||||||
resumedGeneration = generation
|
resumedGeneration = generation
|
||||||
ActiveSessionHolder.set(runtimeId, storageId)
|
ActiveSessionHolder.set(runtimeId, storageId)
|
||||||
sessionHasServerPresence = false
|
sessionHasServerPresence = false
|
||||||
@@ -1488,6 +1517,7 @@ class ChatViewModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
runtimeSessionId = runtimeId
|
runtimeSessionId = runtimeId
|
||||||
|
connectionOperationDelegate.bindSession(runtimeId)
|
||||||
// Resume succeeded — the gateway confirmed the DB row.
|
// Resume succeeded — the gateway confirmed the DB row.
|
||||||
sessionHasServerPresence = true
|
sessionHasServerPresence = true
|
||||||
val sessionId =
|
val sessionId =
|
||||||
@@ -1577,6 +1607,16 @@ class ChatViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val activeSessionId = runtimeSessionId ?: sessionId
|
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<String, Any?>
|
||||||
|
val pendingSnapshot =
|
||||||
|
pendingConnection?.let { ConnectionOperationParser.parse(it, activeSessionId) }
|
||||||
|
request?.connectionCheckpoint?.let { checkpoint ->
|
||||||
|
connectionOperationDelegate.reconcileResume(pendingSnapshot, checkpoint)
|
||||||
|
}
|
||||||
if (activeSessionId != null) approvalsDelegate.replayPendingApproval(activeSessionId)
|
if (activeSessionId != null) approvalsDelegate.replayPendingApproval(activeSessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3436,6 +3476,7 @@ class ChatViewModel(
|
|||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
): Long {
|
): Long {
|
||||||
val generation = ++sessionGeneration
|
val generation = ++sessionGeneration
|
||||||
|
connectionOperationDelegate.reset()
|
||||||
cancelResumeRetry()
|
cancelResumeRetry()
|
||||||
contextUsageJob?.cancel()
|
contextUsageJob?.cancel()
|
||||||
contextUsageJob = null
|
contextUsageJob = null
|
||||||
@@ -3519,6 +3560,7 @@ class ChatViewModel(
|
|||||||
_uiState.update { it.copy(isSessionReady = false) }
|
_uiState.update { it.copy(isSessionReady = false) }
|
||||||
val requestSequence = ++resumeRequestSequence
|
val requestSequence = ++resumeRequestSequence
|
||||||
activeResumeRequestSequence = requestSequence
|
activeResumeRequestSequence = requestSequence
|
||||||
|
val connectionCheckpoint = connectionOperationDelegate.resumeCheckpoint()
|
||||||
val profile = AuthManager.activeProfileId.value
|
val profile = AuthManager.activeProfileId.value
|
||||||
val params =
|
val params =
|
||||||
mutableMapOf<String, Any>(
|
mutableMapOf<String, Any>(
|
||||||
@@ -3539,6 +3581,7 @@ class ChatViewModel(
|
|||||||
generation = generation,
|
generation = generation,
|
||||||
resumeSequence = requestSequence,
|
resumeSequence = requestSequence,
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
|
connectionCheckpoint = connectionCheckpoint,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -4432,8 +4475,15 @@ class ChatViewModel(
|
|||||||
generation: Long,
|
generation: Long,
|
||||||
resumeSequence: Long = 0L,
|
resumeSequence: Long = 0L,
|
||||||
sessionId: String? = null,
|
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)
|
trackRequest(id, method)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4492,6 +4542,22 @@ class ChatViewModel(
|
|||||||
|
|
||||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun respondToConnection(
|
||||||
|
target: String,
|
||||||
|
env: Map<String, String>,
|
||||||
|
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() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
subagentsDelegate.closeSubagentTranscript()
|
subagentsDelegate.closeSubagentTranscript()
|
||||||
|
|||||||
@@ -176,6 +176,10 @@ object ChatWsEventReducer {
|
|||||||
|
|
||||||
is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState)
|
is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState)
|
||||||
|
|
||||||
|
is WsEvent.ConnectionRequest,
|
||||||
|
is WsEvent.ConnectionUpdate,
|
||||||
|
-> ReducerResult(state, streamingState)
|
||||||
|
|
||||||
is WsEvent.Unknown -> onUnknown(state, streamingState)
|
is WsEvent.Unknown -> onUnknown(state, streamingState)
|
||||||
|
|
||||||
// SessionInfo is a no-op in the original code
|
// SessionInfo is a no-op in the original code
|
||||||
|
|||||||
@@ -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<String, String>, 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<String, String>, 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<String, String>().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
|
||||||
@@ -1514,4 +1514,17 @@
|
|||||||
<string name="media_player_play_video">تشغيل الفيديو</string>
|
<string name="media_player_play_video">تشغيل الفيديو</string>
|
||||||
<string name="chat_session_preparing">جارٍ تجهيز الجلسة…</string>
|
<string name="chat_session_preparing">جارٍ تجهيز الجلسة…</string>
|
||||||
<string name="chat_session_not_ready">الجلسة غير جاهزة — أعد المحاولة للمتابعة</string>
|
<string name="chat_session_not_ready">الجلسة غير جاهزة — أعد المحاولة للمتابعة</string>
|
||||||
|
<string name="connection_setup_target_title">إعداد %1$s</string>
|
||||||
|
<string name="connection_setup_progress">%1$d من %2$d</string>
|
||||||
|
<string name="connection_setup_authorized_no_tools">تم التفويض، لكن الأدوات غير متاحة</string>
|
||||||
|
<string name="connection_setup_target_resolved">اكتملت خطوة الاتصال هذه</string>
|
||||||
|
<string name="connection_setup_tools_available">الأدوات المتاحة: %1$d</string>
|
||||||
|
<string name="connection_setup_unsupported">خطوة الإعداد هذه غير مدعومة في إصدار التطبيق الحالي.</string>
|
||||||
|
<string name="connection_setup_connect">اتصال</string>
|
||||||
|
<string name="connection_setup_skip">تخطي</string>
|
||||||
|
<string name="connection_setup_continue">متابعة</string>
|
||||||
|
<string name="connection_setup_open_browser">فتح في المتصفح</string>
|
||||||
|
<string name="connection_setup_required">%1$s مطلوب</string>
|
||||||
|
<string name="connection_setup_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string>
|
||||||
|
<string name="connection_setup_pending">بانتظار Hermes…</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1197,4 +1197,17 @@
|
|||||||
<string name="media_player_play_video">비디오 재생</string>
|
<string name="media_player_play_video">비디오 재생</string>
|
||||||
<string name="chat_session_preparing">세션 준비 중…</string>
|
<string name="chat_session_preparing">세션 준비 중…</string>
|
||||||
<string name="chat_session_not_ready">세션이 준비되지 않았습니다. 다시 시도하세요</string>
|
<string name="chat_session_not_ready">세션이 준비되지 않았습니다. 다시 시도하세요</string>
|
||||||
|
<string name="connection_setup_target_title">%1$s 설정</string>
|
||||||
|
<string name="connection_setup_progress">%2$d개 중 %1$d개</string>
|
||||||
|
<string name="connection_setup_authorized_no_tools">인증되었지만 도구를 사용할 수 없습니다</string>
|
||||||
|
<string name="connection_setup_target_resolved">이 연결 단계가 완료되었습니다</string>
|
||||||
|
<string name="connection_setup_tools_available">사용 가능한 도구: %1$d</string>
|
||||||
|
<string name="connection_setup_unsupported">현재 앱 버전에서는 이 설정 단계를 지원하지 않습니다.</string>
|
||||||
|
<string name="connection_setup_connect">연결</string>
|
||||||
|
<string name="connection_setup_skip">건너뛰기</string>
|
||||||
|
<string name="connection_setup_continue">계속</string>
|
||||||
|
<string name="connection_setup_open_browser">브라우저에서 열기</string>
|
||||||
|
<string name="connection_setup_required">%1$s은(는) 필수입니다</string>
|
||||||
|
<string name="connection_setup_request_failed">설정 응답을 보낼 수 없습니다. 다시 시도하세요.</string>
|
||||||
|
<string name="connection_setup_pending">Hermes를 기다리는 중…</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1467,4 +1467,17 @@
|
|||||||
<string name="media_player_play_video">播放视频</string>
|
<string name="media_player_play_video">播放视频</string>
|
||||||
<string name="chat_session_preparing">正在准备会话…</string>
|
<string name="chat_session_preparing">正在准备会话…</string>
|
||||||
<string name="chat_session_not_ready">会话未就绪,请重试以继续</string>
|
<string name="chat_session_not_ready">会话未就绪,请重试以继续</string>
|
||||||
|
<string name="connection_setup_target_title">设置%1$s</string>
|
||||||
|
<string name="connection_setup_progress">第%1$d项,共%2$d项</string>
|
||||||
|
<string name="connection_setup_authorized_no_tools">已授权,但工具不可用</string>
|
||||||
|
<string name="connection_setup_target_resolved">此连接步骤已完成</string>
|
||||||
|
<string name="connection_setup_tools_available">可用工具:%1$d</string>
|
||||||
|
<string name="connection_setup_unsupported">当前应用版本不支持此设置步骤。</string>
|
||||||
|
<string name="connection_setup_connect">连接</string>
|
||||||
|
<string name="connection_setup_skip">跳过</string>
|
||||||
|
<string name="connection_setup_continue">继续</string>
|
||||||
|
<string name="connection_setup_open_browser">在浏览器中打开</string>
|
||||||
|
<string name="connection_setup_required">%1$s为必填项</string>
|
||||||
|
<string name="connection_setup_request_failed">无法发送设置响应,请重试。</string>
|
||||||
|
<string name="connection_setup_pending">正在等待 Hermes…</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1707,4 +1707,17 @@
|
|||||||
<string name="media_player_play_video">Play video</string>
|
<string name="media_player_play_video">Play video</string>
|
||||||
<string name="chat_session_preparing">Preparing session…</string>
|
<string name="chat_session_preparing">Preparing session…</string>
|
||||||
<string name="chat_session_not_ready">Session not ready — retry to continue</string>
|
<string name="chat_session_not_ready">Session not ready — retry to continue</string>
|
||||||
|
<string name="connection_setup_target_title">Set up %1$s</string>
|
||||||
|
<string name="connection_setup_progress">%1$d of %2$d</string>
|
||||||
|
<string name="connection_setup_authorized_no_tools">Authorized; tools unavailable</string>
|
||||||
|
<string name="connection_setup_target_resolved">This connection step is complete</string>
|
||||||
|
<string name="connection_setup_tools_available">Tools available: %1$d</string>
|
||||||
|
<string name="connection_setup_unsupported">This setup step is not supported by this app version.</string>
|
||||||
|
<string name="connection_setup_connect">Connect</string>
|
||||||
|
<string name="connection_setup_skip">Skip</string>
|
||||||
|
<string name="connection_setup_continue">Continue</string>
|
||||||
|
<string name="connection_setup_open_browser">Open in browser</string>
|
||||||
|
<string name="connection_setup_required">%1$s is required</string>
|
||||||
|
<string name="connection_setup_request_failed">Couldn’t send the setup response. Try again.</string>
|
||||||
|
<string name="connection_setup_pending">Waiting for Hermes…</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -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<String, Any?> =
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Any>()), "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<String, Any?> =
|
||||||
|
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<String, Any?> =
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
+314
@@ -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<String, Any>
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val answer = (result["targets"] as List<Map<String, Any>>).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<String, Any>
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val skipAnswer = (skipResult["targets"] as List<Map<String, Any>>).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<Unit>()
|
||||||
|
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<ConnectionEnvField> = 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<Pair<String, Map<String, Any>>>()
|
||||||
|
|
||||||
|
override suspend fun request(
|
||||||
|
method: String,
|
||||||
|
params: Map<String, Any>,
|
||||||
|
): Any? {
|
||||||
|
calls += method to params
|
||||||
|
failure?.let { throw it }
|
||||||
|
return Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import com.m57.hermescontrol.data.remote.GatewayFileClient
|
|||||||
import com.m57.hermescontrol.data.remote.GatewayFileResult
|
import com.m57.hermescontrol.data.remote.GatewayFileResult
|
||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
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.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.JsonRpcError
|
import com.m57.hermescontrol.data.ws.JsonRpcError
|
||||||
@@ -411,6 +412,77 @@ class ChatViewModelTest {
|
|||||||
return Pair(viewModel, "session-123")
|
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<String, Any?> =
|
||||||
|
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 ──────────────────────────────────────────────────
|
// ── Slash command tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -53,11 +53,13 @@ class SlashCommandDispatchRpcTest {
|
|||||||
private lateinit var app: Application
|
private lateinit var app: Application
|
||||||
private lateinit var fakeRepo: FakeChatPersistenceRepository
|
private lateinit var fakeRepo: FakeChatPersistenceRepository
|
||||||
private var reqCount = 0
|
private var reqCount = 0
|
||||||
|
private var sessionCreateRequestId: String? = null
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
reqCount = 0
|
reqCount = 0
|
||||||
|
sessionCreateRequestId = null
|
||||||
|
|
||||||
mockkStatic(android.util.Log::class)
|
mockkStatic(android.util.Log::class)
|
||||||
every { android.util.Log.d(any(), any()) } returns 0
|
every { android.util.Log.d(any(), any()) } returns 0
|
||||||
@@ -114,6 +116,9 @@ class SlashCommandDispatchRpcTest {
|
|||||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||||
reqCount++
|
reqCount++
|
||||||
val id = "req-id-$reqCount"
|
val id = "req-id-$reqCount"
|
||||||
|
if (arg<String>(0) == WsMethods.SESSION_CREATE) {
|
||||||
|
sessionCreateRequestId = id
|
||||||
|
}
|
||||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
@@ -152,8 +157,8 @@ class SlashCommandDispatchRpcTest {
|
|||||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
// req-id-3 = session.create (after loadSessions + fetchCommandCatalog)
|
val createRequestId = requireNotNull(sessionCreateRequestId) { "session.create was not sent" }
|
||||||
mockEventsFlow.emit(WsEvent.RpcResult("req-id-3", mapOf("session_id" to "session-xyz")))
|
mockEventsFlow.emit(WsEvent.RpcResult(createRequestId, mapOf("session_id" to "session-xyz")))
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
return Pair(vm, "session-xyz")
|
return Pair(vm, "session-xyz")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user