feat(chat): full sudo.request support with expire, cancel-unblock, secret fields (#1056)
This commit is contained in:
10 files changed
+348
-7
No files matched your search
@@ -291,9 +291,21 @@ object EventParser {
|
||||
WsEvent.SudoRequest(requestId, sessionId)
|
||||
}
|
||||
|
||||
"sudo.expire" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
WsEvent.SudoExpire(requestId, sessionId)
|
||||
}
|
||||
|
||||
"secret.request" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
WsEvent.SecretRequest(requestId, sessionId)
|
||||
val envVar = payload?.get("env_var") as? String
|
||||
val prompt = payload?.get("prompt") as? String
|
||||
WsEvent.SecretRequest(requestId, sessionId, envVar, prompt)
|
||||
}
|
||||
|
||||
"secret.expire" -> {
|
||||
val requestId = payload?.get("request_id") as? String
|
||||
WsEvent.SecretExpire(requestId, sessionId)
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
@@ -244,14 +244,37 @@ sealed class WsEvent {
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
/**
|
||||
* Backend sudo timeout (120s `_block`) — clears the matching dialog.
|
||||
* Payload: `{ request_id }`. Only clears when ids match so a late
|
||||
* expire for an old prompt never kills the current one (desktop parity).
|
||||
*/
|
||||
data class SudoExpire(
|
||||
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.
|
||||
* `envVar`/`prompt` mirror desktop: title = envVar ?: secretTitle,
|
||||
* body = prompt ?: secretDesc.
|
||||
*/
|
||||
data class SecretRequest(
|
||||
val requestId: String?,
|
||||
val sessionId: String?,
|
||||
val envVar: String? = null,
|
||||
val prompt: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
/**
|
||||
* Backend secret timeout — clears the matching dialog.
|
||||
* Payload: `{ request_id }`. Match-only clear like [SudoExpire].
|
||||
*/
|
||||
data class SecretExpire(
|
||||
val requestId: String?,
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Gateway-level errors ───────────────────────────────────────────
|
||||
|
||||
@@ -119,6 +119,18 @@ class ChatNotificationService : Service() {
|
||||
showReplyNotification(getString(R.string.notif_clarification_needed), null)
|
||||
}
|
||||
|
||||
is WsEvent.SudoRequest -> {
|
||||
showReplyNotification(getString(R.string.notif_input_needed), null)
|
||||
}
|
||||
|
||||
is WsEvent.SecretRequest -> {
|
||||
val body =
|
||||
event.prompt?.takeIf { it.isNotBlank() }
|
||||
?: event.envVar?.takeIf { it.isNotBlank() }
|
||||
?: getString(R.string.notif_input_needed)
|
||||
showReplyNotification(body, null)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +444,8 @@ data class SudoPromptUi(
|
||||
data class SecretPromptUi(
|
||||
val requestId: String?,
|
||||
val sessionId: String?,
|
||||
val envVar: String? = null,
|
||||
val prompt: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -1007,10 +1009,18 @@ class ChatViewModel(
|
||||
handleSudoRequest(event)
|
||||
}
|
||||
|
||||
is WsEvent.SudoExpire -> {
|
||||
handleSudoExpire(event)
|
||||
}
|
||||
|
||||
is WsEvent.SecretRequest -> {
|
||||
handleSecretRequest(event)
|
||||
}
|
||||
|
||||
is WsEvent.SecretExpire -> {
|
||||
handleSecretExpire(event)
|
||||
}
|
||||
|
||||
is WsEvent.GatewayError -> {
|
||||
// Reducer already set errorMessage; no extra VM work needed.
|
||||
}
|
||||
@@ -3651,6 +3661,23 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend sudo timeout (120s) — clear only the matching dialog so a
|
||||
* late expire for an old prompt never kills the current one.
|
||||
* Desktop parity: `patchOverlayState(prev => prev.sudo?.requestId === id ? null : prev)`.
|
||||
*/
|
||||
private fun handleSudoExpire(event: WsEvent.SudoExpire) {
|
||||
_uiState.update { state ->
|
||||
val current = state.sudoPrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(sudoPrompt = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent needs a secret value (token/password). Previously dropped →
|
||||
* agent hung forever. Now we surface a secure dialog and reply via
|
||||
@@ -3659,18 +3686,82 @@ class ChatViewModel(
|
||||
private fun handleSecretRequest(event: WsEvent.SecretRequest) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
secretPrompt = SecretPromptUi(event.requestId, event.sessionId),
|
||||
secretPrompt =
|
||||
SecretPromptUi(
|
||||
event.requestId,
|
||||
event.sessionId,
|
||||
event.envVar,
|
||||
event.prompt,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissSudo() {
|
||||
_uiState.update { it.copy(sudoPrompt = null) }
|
||||
/**
|
||||
* Backend secret timeout — match-only clear like [handleSudoExpire].
|
||||
*/
|
||||
private fun handleSecretExpire(event: WsEvent.SecretExpire) {
|
||||
_uiState.update { state ->
|
||||
val current = state.secretPrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(secretPrompt = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel → send empty password (desktop parity:
|
||||
* `prompt-overlays.tsx` `send('')`). Backend treats empty sudo as
|
||||
* failed sudo (no command runs), so closing the dialog is a safe
|
||||
* refusal that unblocks the turn instantly instead of hanging 120s
|
||||
* until `sudo.expire`.
|
||||
*/
|
||||
fun dismissSudo() {
|
||||
val prompt = _uiState.value.sudoPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId
|
||||
_uiState.update { it.copy(sudoPrompt = null) }
|
||||
if (sessionId == null) return
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"password" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsClient.send(
|
||||
method = WsMethods.SUDO_RESPOND,
|
||||
params = params,
|
||||
onSent = { id -> trackRequest(id, WsMethods.SUDO_RESPOND) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel → send empty value (desktop parity). Backend `secret_cb`
|
||||
* returns `skipped=True` on empty, unblocking the turn instantly.
|
||||
*/
|
||||
fun dismissSecret() {
|
||||
val prompt = _uiState.value.secretPrompt ?: return
|
||||
val sessionId = prompt.sessionId ?: _uiState.value.currentSessionId
|
||||
_uiState.update { it.copy(secretPrompt = null) }
|
||||
if (sessionId == null) return
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
"value" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsClient.send(
|
||||
method = WsMethods.SECRET_RESPOND,
|
||||
params = params,
|
||||
onSent = { id -> trackRequest(id, WsMethods.SECRET_RESPOND) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -163,8 +163,12 @@ object ChatWsEventReducer {
|
||||
// SudoRequest / SecretRequest are handled by the ViewModel (issue #524)
|
||||
is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
is WsEvent.SudoExpire -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
is WsEvent.SecretRequest -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
is WsEvent.SecretExpire -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
// ReactionEvent is handled by the ViewModel — purely cosmetic animation
|
||||
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
|
||||
@@ -87,20 +87,25 @@ fun SudoPromptDialog(
|
||||
fun SecretPromptDialog(
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
envVar: String? = null,
|
||||
prompt: String? = null,
|
||||
) {
|
||||
var secret by remember { mutableStateOf("") }
|
||||
val titleText = envVar?.takeIf { it.isNotBlank() } ?: stringResource(R.string.chat_secret_title)
|
||||
val bodyText = prompt?.takeIf { it.isNotBlank() } ?: stringResource(R.string.chat_secret_body)
|
||||
val labelText = envVar?.takeIf { it.isNotBlank() } ?: stringResource(R.string.chat_secret_value)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.chat_secret_title)) },
|
||||
title = { Text(titleText) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = stringResource(R.string.chat_secret_body))
|
||||
Text(text = bodyText)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedTextField(
|
||||
value = secret,
|
||||
onValueChange = { secret = it },
|
||||
label = { Text(stringResource(R.string.chat_secret_value)) },
|
||||
label = { Text(labelText) },
|
||||
modifier = Modifier.fillMaxWidth().testTag("secret_value_input"),
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
|
||||
@@ -133,6 +133,8 @@ fun ChatLifecycleEffects(
|
||||
SecretPromptDialog(
|
||||
onConfirm = viewModel::respondToSecret,
|
||||
onDismiss = viewModel::dismissSecret,
|
||||
envVar = prompt.envVar,
|
||||
prompt = prompt.prompt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,7 @@
|
||||
<string name="notif_waiting_replies">Waiting for Hermes replies</string>
|
||||
<string name="notif_new_message">New message</string>
|
||||
<string name="notif_clarification_needed">Hermes needs a clarification</string>
|
||||
<string name="notif_input_needed">Hermes needs your input</string>
|
||||
<string name="notif_reply_placeholder">Type your reply\u2026</string>
|
||||
<string name="notif_channel_service_name">Hermes Background Service</string>
|
||||
<string name="notif_channel_service_desc">Keeps connection alive in the background</string>
|
||||
|
||||
@@ -855,4 +855,71 @@ class EventParserTest {
|
||||
assertEquals("1", todoUpdated.todos[1].parent)
|
||||
assertTrue(todoUpdated.todos[1].isSubtask)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseSudoRequest_returnsSudoRequest() {
|
||||
val params =
|
||||
mapOf(
|
||||
"type" to "sudo.request",
|
||||
"session_id" to "sess-sudo-1",
|
||||
"payload" to mapOf("request_id" to "sudo-1"),
|
||||
)
|
||||
val event = EventParser.parseParams(params)
|
||||
assertTrue(event is WsEvent.SudoRequest)
|
||||
val sudo = event as WsEvent.SudoRequest
|
||||
assertEquals("sudo-1", sudo.requestId)
|
||||
assertEquals("sess-sudo-1", sudo.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseSudoExpire_returnsSudoExpire() {
|
||||
val params =
|
||||
mapOf(
|
||||
"type" to "sudo.expire",
|
||||
"session_id" to "sess-sudo-1",
|
||||
"payload" to mapOf("request_id" to "sudo-1"),
|
||||
)
|
||||
val event = EventParser.parseParams(params)
|
||||
assertTrue(event is WsEvent.SudoExpire)
|
||||
val expire = event as WsEvent.SudoExpire
|
||||
assertEquals("sudo-1", expire.requestId)
|
||||
assertEquals("sess-sudo-1", expire.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseSecretRequest_withEnvVarAndPrompt() {
|
||||
val params =
|
||||
mapOf(
|
||||
"type" to "secret.request",
|
||||
"session_id" to "sess-secret-1",
|
||||
"payload" to
|
||||
mapOf(
|
||||
"request_id" to "secret-1",
|
||||
"env_var" to "GITHUB_TOKEN",
|
||||
"prompt" to "Enter your GitHub token to continue",
|
||||
),
|
||||
)
|
||||
val event = EventParser.parseParams(params)
|
||||
assertTrue(event is WsEvent.SecretRequest)
|
||||
val secret = event as WsEvent.SecretRequest
|
||||
assertEquals("secret-1", secret.requestId)
|
||||
assertEquals("sess-secret-1", secret.sessionId)
|
||||
assertEquals("GITHUB_TOKEN", secret.envVar)
|
||||
assertEquals("Enter your GitHub token to continue", secret.prompt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseSecretExpire_returnsSecretExpire() {
|
||||
val params =
|
||||
mapOf(
|
||||
"type" to "secret.expire",
|
||||
"session_id" to "sess-secret-1",
|
||||
"payload" to mapOf("request_id" to "secret-1"),
|
||||
)
|
||||
val event = EventParser.parseParams(params)
|
||||
assertTrue(event is WsEvent.SecretExpire)
|
||||
val expire = event as WsEvent.SecretExpire
|
||||
assertEquals("secret-1", expire.requestId)
|
||||
assertEquals("sess-secret-1", expire.sessionId)
|
||||
}
|
||||
}
|
||||
@@ -3159,6 +3159,130 @@ class ChatViewModelTest {
|
||||
assertNull(viewModel.uiState.value.secretPrompt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSudoExpire_clearsMatchingPrompt() =
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
mockEventsFlow.emit(WsEvent.SudoRequest(requestId = "sudo-1", sessionId = null))
|
||||
advanceUntilIdle()
|
||||
assertNotNull(viewModel.uiState.value.sudoPrompt)
|
||||
|
||||
mockEventsFlow.emit(WsEvent.SudoExpire(requestId = "sudo-1", sessionId = null))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(viewModel.uiState.value.sudoPrompt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSudoExpire_ignoresNonMatchingRequestId() =
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
mockEventsFlow.emit(WsEvent.SudoRequest(requestId = "sudo-2", sessionId = null))
|
||||
advanceUntilIdle()
|
||||
assertNotNull(viewModel.uiState.value.sudoPrompt)
|
||||
|
||||
mockEventsFlow.emit(WsEvent.SudoExpire(requestId = "sudo-stale", sessionId = null))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNotNull(viewModel.uiState.value.sudoPrompt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSecretExpire_clearsMatchingPrompt() =
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
mockEventsFlow.emit(WsEvent.SecretRequest(requestId = "secret-1", sessionId = null))
|
||||
advanceUntilIdle()
|
||||
assertNotNull(viewModel.uiState.value.secretPrompt)
|
||||
|
||||
mockEventsFlow.emit(WsEvent.SecretExpire(requestId = "secret-1", sessionId = null))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(viewModel.uiState.value.secretPrompt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSecretRequest_setsEnvVarAndPrompt() =
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.SecretRequest(
|
||||
requestId = "secret-1",
|
||||
sessionId = null,
|
||||
envVar = "GITHUB_TOKEN",
|
||||
prompt = "Enter your GitHub token",
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
val prompt = viewModel.uiState.value.secretPrompt
|
||||
assertNotNull(prompt)
|
||||
assertEquals("GITHUB_TOKEN", prompt?.envVar)
|
||||
assertEquals("Enter your GitHub token", prompt?.prompt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDismissSudo_sendsEmptyPassword() =
|
||||
runTest {
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
mockEventsFlow.emit(WsEvent.SudoRequest(requestId = "sudo-1", sessionId = null))
|
||||
advanceUntilIdle()
|
||||
assertNotNull(viewModel.uiState.value.sudoPrompt)
|
||||
|
||||
viewModel.dismissSudo()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(viewModel.uiState.value.sudoPrompt)
|
||||
verify {
|
||||
HermesWsClient.send(
|
||||
WsMethods.SUDO_RESPOND,
|
||||
withArg { params ->
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals("", params["password"])
|
||||
assertEquals("sudo-1", params["request_id"])
|
||||
},
|
||||
any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDismissSecret_sendsEmptyValue() =
|
||||
runTest {
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
mockEventsFlow.emit(WsEvent.SecretRequest(requestId = "secret-1", sessionId = null))
|
||||
advanceUntilIdle()
|
||||
assertNotNull(viewModel.uiState.value.secretPrompt)
|
||||
|
||||
viewModel.dismissSecret()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(viewModel.uiState.value.secretPrompt)
|
||||
verify {
|
||||
HermesWsClient.send(
|
||||
WsMethods.SECRET_RESPOND,
|
||||
withArg { params ->
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals("", params["value"])
|
||||
assertEquals("secret-1", params["request_id"])
|
||||
},
|
||||
any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user