Previously mobile dropped the gateway's sudo.request and secret.request events, so any agent turn needing a sudo password or a secret value hung forever with no UI. Now: - EventParser maps sudo.request / secret.request -> WsEvent.SudoRequest / SecretRequest - ChatViewModel surfaces a secure dialog (SudoPromptUi / SecretPromptUi) and replies via sudo.respond / secret.respond with session_id + request_id + payload - ChatWsEventReducer explicitly branches the new events (no-op, VM-owned) - ChatScreen renders password-masked SudoPromptDialog / SecretPromptDialog - 6 unit tests cover prompt-state, RPC params, and prompt-clearing Mirrors desktop/web contract (apps/shared GatewayEventName + tui_gateway). Derives from audit of /opt/hermes-agent desktop app.
This commit is contained in:
8 files changed
+400
No files matched your search
@@ -115,6 +115,16 @@ object EventParser {
|
|||||||
WsEvent.ApprovalRequest(command, description, patternKeys, sessionId)
|
WsEvent.ApprovalRequest(command, description, patternKeys, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"sudo.request" -> {
|
||||||
|
val requestId = payload?.get("request_id") as? String
|
||||||
|
WsEvent.SudoRequest(requestId, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
"secret.request" -> {
|
||||||
|
val requestId = payload?.get("request_id") as? String
|
||||||
|
WsEvent.SecretRequest(requestId, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Log.w(TAG, "Unknown event type: $eventType")
|
Log.w(TAG, "Unknown event type: $eventType")
|
||||||
WsEvent.Unknown(rawJson)
|
WsEvent.Unknown(rawJson)
|
||||||
|
|||||||
@@ -94,6 +94,28 @@ sealed class WsEvent {
|
|||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
|
// ── Sudo / secret requests ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend needs the user's sudo password to continue a turn
|
||||||
|
* (desktop: `sudo.request` → `sudo.respond {request_id, password}`).
|
||||||
|
* Mobile previously dropped this and the agent hung forever.
|
||||||
|
*/
|
||||||
|
data class SudoRequest(
|
||||||
|
val requestId: String?,
|
||||||
|
val sessionId: String?,
|
||||||
|
) : WsEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend needs a secret value (password / token) to continue a turn
|
||||||
|
* (desktop: `secret.request` → `secret.respond {request_id, value}`).
|
||||||
|
* Mobile previously dropped this and the agent hung forever.
|
||||||
|
*/
|
||||||
|
data class SecretRequest(
|
||||||
|
val requestId: String?,
|
||||||
|
val sessionId: String?,
|
||||||
|
) : WsEvent()
|
||||||
|
|
||||||
// ── Fallback ─────────────────────────────────────────────────────────
|
// ── Fallback ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class Unknown(
|
data class Unknown(
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ object WsMethods {
|
|||||||
const val PROMPT_SUBMIT = "prompt.submit"
|
const val PROMPT_SUBMIT = "prompt.submit"
|
||||||
const val CLARIFY_RESPOND = "clarify.respond"
|
const val CLARIFY_RESPOND = "clarify.respond"
|
||||||
const val APPROVAL_RESPOND = "approval.respond"
|
const val APPROVAL_RESPOND = "approval.respond"
|
||||||
|
const val SUDO_RESPOND = "sudo.respond"
|
||||||
|
const val SECRET_RESPOND = "secret.respond"
|
||||||
|
|
||||||
// ── Commands catalog ──────────────────────────────────────────────────
|
// ── Commands catalog ──────────────────────────────────────────────────
|
||||||
const val COMMANDS_CATALOG = "commands.catalog"
|
const val COMMANDS_CATALOG = "commands.catalog"
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -275,6 +277,8 @@ fun ChatScreen(
|
|||||||
currentSearchMatchIndex = state.currentSearchMatchIndex,
|
currentSearchMatchIndex = state.currentSearchMatchIndex,
|
||||||
searchMatchIndices = state.searchMatchIndices,
|
searchMatchIndices = state.searchMatchIndices,
|
||||||
clarifyRequest = state.clarifyRequest,
|
clarifyRequest = state.clarifyRequest,
|
||||||
|
sudoPrompt = state.sudoPrompt,
|
||||||
|
secretPrompt = state.secretPrompt,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
@@ -1004,6 +1008,106 @@ private fun ClarifyDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secure password dialog for a pending `sudo.request` (issue #524).
|
||||||
|
* The backend blocked the turn waiting for the sudo password — previously
|
||||||
|
* mobile dropped the event and the agent hung forever.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SudoPromptDialog(
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.chat_sudo_title)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(text = stringResource(R.string.chat_sudo_body))
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text(stringResource(R.string.chat_sudo_password)) },
|
||||||
|
modifier = Modifier.fillMaxWidth().testTag("sudo_password_input"),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(autoCorrect = false, keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (password.isNotBlank()) {
|
||||||
|
onConfirm(password)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = password.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.chat_send))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.chat_dismiss))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secure value dialog for a pending `secret.request` (issue #524).
|
||||||
|
* The backend blocked the turn waiting for a secret (token/password) —
|
||||||
|
* previously mobile dropped the event and the agent hung forever.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SecretPromptDialog(
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var secret by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.chat_secret_title)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(text = stringResource(R.string.chat_secret_body))
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = secret,
|
||||||
|
onValueChange = { secret = it },
|
||||||
|
label = { Text(stringResource(R.string.chat_secret_value)) },
|
||||||
|
modifier = Modifier.fillMaxWidth().testTag("secret_value_input"),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(autoCorrect = false, keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (secret.isNotBlank()) {
|
||||||
|
onConfirm(secret)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = secret.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.chat_send))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.chat_dismiss))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CompactSearchInput(
|
private fun CompactSearchInput(
|
||||||
value: String,
|
value: String,
|
||||||
@@ -1230,6 +1334,8 @@ private fun ChatLifecycleEffects(
|
|||||||
currentSearchMatchIndex: Int,
|
currentSearchMatchIndex: Int,
|
||||||
searchMatchIndices: List<Int>,
|
searchMatchIndices: List<Int>,
|
||||||
clarifyRequest: ClarifyUi?,
|
clarifyRequest: ClarifyUi?,
|
||||||
|
sudoPrompt: SudoPromptUi?,
|
||||||
|
secretPrompt: SecretPromptUi?,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
snackbarHostState: SnackbarHostState,
|
snackbarHostState: SnackbarHostState,
|
||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
@@ -1330,6 +1436,21 @@ private fun ChatLifecycleEffects(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sudo / secret prompt dialogs (issue #524)
|
||||||
|
sudoPrompt?.let { prompt ->
|
||||||
|
SudoPromptDialog(
|
||||||
|
onConfirm = viewModel::respondToSudo,
|
||||||
|
onDismiss = viewModel::dismissSudo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretPrompt?.let { prompt ->
|
||||||
|
SecretPromptDialog(
|
||||||
|
onConfirm = viewModel::respondToSecret,
|
||||||
|
onDismiss = viewModel::dismissSecret,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Scroll to current search match
|
// Scroll to current search match
|
||||||
LaunchedEffect(isSearchActive, currentSearchMatchIndex, searchMatchIndices) {
|
LaunchedEffect(isSearchActive, currentSearchMatchIndex, searchMatchIndices) {
|
||||||
if (isSearchActive &&
|
if (isSearchActive &&
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ data class ChatUiState(
|
|||||||
val streamingMessage: ChatMessage? = null,
|
val streamingMessage: ChatMessage? = null,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val clarifyRequest: ClarifyUi? = null,
|
val clarifyRequest: ClarifyUi? = null,
|
||||||
|
// Sudo / secret prompts — surfaced as dialogs (issue #524)
|
||||||
|
val sudoPrompt: SudoPromptUi? = null,
|
||||||
|
val secretPrompt: SecretPromptUi? = null,
|
||||||
val showSessionPicker: Boolean = false,
|
val showSessionPicker: Boolean = false,
|
||||||
// Search state
|
// Search state
|
||||||
val isSearchActive: Boolean = false,
|
val isSearchActive: Boolean = false,
|
||||||
@@ -100,6 +103,18 @@ data class ClarifyUi(
|
|||||||
val clarifyId: String? = null,
|
val clarifyId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Transient — not persisted. Holds a pending sudo.password request. */
|
||||||
|
data class SudoPromptUi(
|
||||||
|
val requestId: String?,
|
||||||
|
val sessionId: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Transient — not persisted. Holds a pending secret (token/password) request. */
|
||||||
|
data class SecretPromptUi(
|
||||||
|
val requestId: String?,
|
||||||
|
val sessionId: String?,
|
||||||
|
)
|
||||||
|
|
||||||
class ChatViewModel(
|
class ChatViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
private val startCleanup: Boolean,
|
private val startCleanup: Boolean,
|
||||||
@@ -358,6 +373,14 @@ class ChatViewModel(
|
|||||||
handleApprovalRequest(event)
|
handleApprovalRequest(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is WsEvent.SudoRequest -> {
|
||||||
|
handleSudoRequest(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
is WsEvent.SecretRequest -> {
|
||||||
|
handleSecretRequest(event)
|
||||||
|
}
|
||||||
|
|
||||||
else -> { /* reducer handles these */ }
|
else -> { /* reducer handles these */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1096,6 +1119,94 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sudo / secret prompt flow (issue #524) ──────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The agent needs the user's sudo password. Previously dropped → agent
|
||||||
|
* hung forever. Now we surface a secure dialog and reply via sudo.respond.
|
||||||
|
*/
|
||||||
|
private fun handleSudoRequest(event: WsEvent.SudoRequest) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
sudoPrompt = SudoPromptUi(event.requestId, event.sessionId),
|
||||||
|
isAgentTyping = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The agent needs a secret value (token/password). Previously dropped →
|
||||||
|
* agent hung forever. Now we surface a secure dialog and reply via
|
||||||
|
* secret.respond.
|
||||||
|
*/
|
||||||
|
private fun handleSecretRequest(event: WsEvent.SecretRequest) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
secretPrompt = SecretPromptUi(event.requestId, event.sessionId),
|
||||||
|
isAgentTyping = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissSudo() {
|
||||||
|
_uiState.update { it.copy(sudoPrompt = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissSecret() {
|
||||||
|
_uiState.update { it.copy(secretPrompt = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the user's sudo password back to the gateway. Mirrors
|
||||||
|
* respondToApproval: clear the prompt immediately, then fire the RPC.
|
||||||
|
*/
|
||||||
|
fun respondToSudo(password: String) {
|
||||||
|
val prompt = _uiState.value.sudoPrompt ?: return
|
||||||
|
val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId ?: return
|
||||||
|
if (password.isBlank()) return
|
||||||
|
|
||||||
|
_uiState.update { it.copy(sudoPrompt = null) }
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val params =
|
||||||
|
mutableMapOf<String, Any>(
|
||||||
|
"session_id" to sessionId,
|
||||||
|
"password" to password,
|
||||||
|
)
|
||||||
|
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||||
|
wsClient.send(
|
||||||
|
method = WsMethods.SUDO_RESPOND,
|
||||||
|
params = params,
|
||||||
|
onSent = { id -> trackRequest(id, WsMethods.SUDO_RESPOND) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the user's secret value back to the gateway. Mirrors respondToSudo.
|
||||||
|
*/
|
||||||
|
fun respondToSecret(value: String) {
|
||||||
|
val prompt = _uiState.value.secretPrompt ?: return
|
||||||
|
val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId ?: return
|
||||||
|
if (value.isBlank()) return
|
||||||
|
|
||||||
|
_uiState.update { it.copy(secretPrompt = null) }
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val params =
|
||||||
|
mutableMapOf<String, Any>(
|
||||||
|
"session_id" to sessionId,
|
||||||
|
"value" to value,
|
||||||
|
)
|
||||||
|
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||||
|
wsClient.send(
|
||||||
|
method = WsMethods.SECRET_RESPOND,
|
||||||
|
params = params,
|
||||||
|
onSent = { id -> trackRequest(id, WsMethods.SECRET_RESPOND) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun reconnect() {
|
fun reconnect() {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ object ChatWsEventReducer {
|
|||||||
|
|
||||||
// ApprovalRequest is handled by the ViewModel (needs active session + WS client)
|
// ApprovalRequest is handled by the ViewModel (needs active session + WS client)
|
||||||
is WsEvent.ApprovalRequest -> ReducerResult(state = state, streamingState = streamingState)
|
is WsEvent.ApprovalRequest -> ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
|
// SudoRequest / SecretRequest are handled by the ViewModel (issue #524)
|
||||||
|
is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
is WsEvent.SecretRequest -> ReducerResult(state = state, streamingState = streamingState)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GatewayReady ──────────────────────────────────────────────────
|
// ── GatewayReady ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
<string name="chat_clarify_response">Response</string>
|
<string name="chat_clarify_response">Response</string>
|
||||||
<string name="chat_send">Send</string>
|
<string name="chat_send">Send</string>
|
||||||
<string name="chat_dismiss">Dismiss</string>
|
<string name="chat_dismiss">Dismiss</string>
|
||||||
|
<string name="chat_sudo_title">Sudo Password Required</string>
|
||||||
|
<string name="chat_sudo_body">The agent needs your sudo password to continue. It will not be shown.</string>
|
||||||
|
<string name="chat_sudo_password">Password</string>
|
||||||
|
<string name="chat_secret_title">Secret Required</string>
|
||||||
|
<string name="chat_secret_body">The agent needs a secret value (token or password) to continue. It will not be shown.</string>
|
||||||
|
<string name="chat_secret_value">Secret</string>
|
||||||
<string name="chat_search_placeholder">Search messages\u2026</string>
|
<string name="chat_search_placeholder">Search messages\u2026</string>
|
||||||
<string name="chat_clear_search_desc">Clear</string>
|
<string name="chat_clear_search_desc">Clear</string>
|
||||||
<string name="chat_prev_match_desc">Previous match</string>
|
<string name="chat_prev_match_desc">Previous match</string>
|
||||||
|
|||||||
@@ -1035,6 +1035,130 @@ class ChatViewModelTest {
|
|||||||
assertNull(msgAfter!!.approvalInfo)
|
assertNull(msgAfter!!.approvalInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sudo / secret prompt flow (issue #524) ───────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSudoRequest_setsPromptState() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
mockEventsFlow.emit(
|
||||||
|
WsEvent.SudoRequest(requestId = "sudo-1", sessionId = null),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val prompt = viewModel.uiState.value.sudoPrompt
|
||||||
|
assertNotNull(prompt)
|
||||||
|
assertEquals("sudo-1", prompt?.requestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRespondToSudo_sendsRpc() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
mockEventsFlow.emit(
|
||||||
|
WsEvent.SudoRequest(requestId = "sudo-1", sessionId = null),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.respondToSudo("hunter2")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verify {
|
||||||
|
HermesWsClient.send(
|
||||||
|
WsMethods.SUDO_RESPOND,
|
||||||
|
withArg { params ->
|
||||||
|
assertEquals(sessionId, params["session_id"])
|
||||||
|
assertEquals("hunter2", params["password"])
|
||||||
|
assertEquals("sudo-1", params["request_id"])
|
||||||
|
},
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRespondToSudo_clearsPrompt() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
mockEventsFlow.emit(
|
||||||
|
WsEvent.SudoRequest(requestId = "sudo-1", sessionId = null),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertNotNull(viewModel.uiState.value.sudoPrompt)
|
||||||
|
|
||||||
|
viewModel.respondToSudo("hunter2")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNull(viewModel.uiState.value.sudoPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSecretRequest_setsPromptState() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
mockEventsFlow.emit(
|
||||||
|
WsEvent.SecretRequest(requestId = "secret-1", sessionId = null),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val prompt = viewModel.uiState.value.secretPrompt
|
||||||
|
assertNotNull(prompt)
|
||||||
|
assertEquals("secret-1", prompt?.requestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRespondToSecret_sendsRpc() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
mockEventsFlow.emit(
|
||||||
|
WsEvent.SecretRequest(requestId = "secret-1", sessionId = null),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.respondToSecret("super-secret-token")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verify {
|
||||||
|
HermesWsClient.send(
|
||||||
|
WsMethods.SECRET_RESPOND,
|
||||||
|
withArg { params ->
|
||||||
|
assertEquals(sessionId, params["session_id"])
|
||||||
|
assertEquals("super-secret-token", params["value"])
|
||||||
|
assertEquals("secret-1", params["request_id"])
|
||||||
|
},
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRespondToSecret_clearsPrompt() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
mockEventsFlow.emit(
|
||||||
|
WsEvent.SecretRequest(requestId = "secret-1", sessionId = null),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertNotNull(viewModel.uiState.value.secretPrompt)
|
||||||
|
|
||||||
|
viewModel.respondToSecret("super-secret-token")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNull(viewModel.uiState.value.secretPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Settings ─────────────────────────────────────────────────────────────
|
// ── Settings ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in new issue
Block a user