refactor(chat): extract credential prompts delegate with tests

This commit is contained in:
Hy4ri committed 2026-09-11 00:03:42 +03:00
1 parent 54dbb99a3d
commit 9d35bf7cdc
2 files changed
+267

No files matched your search

@@ -0,0 +1,180 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* Owns sudo and secret prompt state, timeouts, dismissal, and response RPCs.
*
* Extracted behavior-preservingly from [ChatViewModel] (issue #524).
*/
class ChatCredentialPromptsDelegate(
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher,
private val uiState: MutableStateFlow<ChatUiState>,
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
private val trackRequest: (id: String, method: String) -> Unit,
) {
/**
* The agent needs the user's sudo password. Surface a secure dialog and
* reply via sudo.respond.
*/
fun handleSudoRequest(event: WsEvent.SudoRequest) {
uiState.update {
it.copy(
sudoPrompt = SudoPromptUi(event.requestId, event.sessionId),
isAgentTyping = false,
)
}
}
/**
* Backend sudo timeout (120s) — clear only the matching dialog so a late
* expire for an old prompt never kills the current one.
*/
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). Surface a secure dialog
* and reply via secret.respond.
*/
fun handleSecretRequest(event: WsEvent.SecretRequest) {
uiState.update {
it.copy(
secretPrompt =
SecretPromptUi(
event.requestId,
event.sessionId,
event.envVar,
event.prompt,
),
isAgentTyping = false,
)
}
}
/**
* Backend secret timeout — match-only clear like [handleSudoExpire].
*/
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). Backend treats empty sudo as
* failed sudo (no command runs), unblocking the turn instantly.
*/
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
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"password" to "",
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.SUDO_RESPOND,
params,
) { 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
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"value" to "",
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.SECRET_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
}
}
/**
* Send the user's sudo password back to the gateway. Clear 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) }
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"password" to password,
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.SUDO_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) }
}
}
/**
* Send the user's secret value back to the gateway.
*/
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) }
scope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"value" to value,
)
prompt.requestId?.let { id -> params["request_id"] = id }
wsSend(
WsMethods.SECRET_RESPOND,
params,
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
}
}
}
@@ -0,0 +1,87 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChatCredentialPromptsDelegateTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "sess-1"))
private val sentMethods = mutableListOf<String>()
private val sentParams = mutableListOf<Map<String, Any>>()
private val trackedRequests = mutableListOf<Pair<String, String>>()
private val delegate =
ChatCredentialPromptsDelegate(
scope = testScope,
ioDispatcher = testDispatcher,
uiState = uiState,
wsSend = { method, params, onSent ->
sentMethods.add(method)
sentParams.add(params)
onSent?.invoke("req-${sentMethods.size}")
},
trackRequest = { id, method -> trackedRequests.add(id to method) },
)
@Test
fun handleSudoRequest_andRespond_clearsPromptAndSendsRpc() =
testScope.runTest {
delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "r-sudo", sessionId = "s-1"))
assertEquals("r-sudo", uiState.value.sudoPrompt?.requestId)
assertFalse(uiState.value.isAgentTyping)
delegate.respondToSudo("secret-pass")
assertNull(uiState.value.sudoPrompt)
advanceUntilIdle()
assertEquals(listOf(WsMethods.SUDO_RESPOND), sentMethods)
assertEquals("secret-pass", sentParams.first()["password"])
assertEquals("r-sudo", sentParams.first()["request_id"])
assertEquals("s-1", sentParams.first()["session_id"])
}
@Test
fun handleSecretRequest_andDismiss_sendsEmptyValue() =
testScope.runTest {
delegate.handleSecretRequest(
WsEvent.SecretRequest(
requestId = "r-sec",
sessionId = "s-1",
envVar = "OPENAI_API_KEY",
prompt = "Enter key",
),
)
assertEquals("OPENAI_API_KEY", uiState.value.secretPrompt?.envVar)
delegate.dismissSecret()
assertNull(uiState.value.secretPrompt)
advanceUntilIdle()
assertEquals(listOf(WsMethods.SECRET_RESPOND), sentMethods)
assertEquals("", sentParams.first()["value"])
assertEquals("r-sec", sentParams.first()["request_id"])
}
@Test
fun expireHandlers_ignoreMismatchedRequestId() {
delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "keep-me", sessionId = "s-1"))
delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "other-req", sessionId = "s-1"))
assertNotNull(uiState.value.sudoPrompt)
delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "keep-me", sessionId = "s-1"))
assertNull(uiState.value.sudoPrompt)
}
}