feat(chat): handle sudo.request / secret.request and respond via RPC (fixes #524) (#539)

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:
M57 authored and GitHub committed 2026-07-10 05:00:23 +03:00
1 parent f6e6f86cc4
commit d056c77c93
8 files changed
+400

No files matched your search

@@ -1035,6 +1035,130 @@ class ChatViewModelTest {
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 ─────────────────────────────────────────────────────────────
@Test