fix(#1119): handle gateway server requests (#1125)

This commit is contained in:
M57 authored and GitHub committed 2026-09-15 13:09:11 +03:00
1 parent fb0f93c000
commit e4be8b146e
16 files changed
+1169 -119

No files matched your search

@@ -8,8 +8,12 @@ import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
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
@@ -196,4 +200,111 @@ class ChatApprovalsDelegateTest {
delegate.handleApprovalRespondResult(mapOf("resolved" to 1))
assertEquals(listOf("Approval submitted"), systemMessages)
}
@Test
fun cancelServerRequest_matchesServerRequestIdOnly() {
delegate.handleApprovalRequest(
WsEvent.ApprovalRequest(
command = "rm -rf /tmp/x",
description = "dangerous",
patternKeys = null,
sessionId = "session-1",
requestId = "approval-1",
serverRequestId = "srq-approval-1",
),
)
delegate.cancelServerRequest("srq-other")
assertNotNull(
uiState.value.messages
.last()
.approvalInfo,
)
delegate.cancelServerRequest("srq-approval-1")
assertNull(
uiState.value.messages
.last()
.approvalInfo,
)
}
@Test
fun liveServerApproval_upgradesMatchingPendingApprovalWithoutDuplicating() {
delegate.handleApprovalRequest(
WsEvent.ApprovalRequest(
command = "git status",
description = "inspect",
patternKeys = null,
sessionId = "session-1",
requestId = "approval-business-id",
),
)
delegate.handleApprovalRequest(
WsEvent.ApprovalRequest(
command = "git status",
description = "inspect",
patternKeys = null,
sessionId = "session-1",
requestId = "approval-business-id",
serverRequestId = "srq-approval",
),
)
assertEquals(1, uiState.value.messages.size)
assertEquals(
"srq-approval",
uiState.value.messages
.single()
.approvalInfo
?.serverRequestId,
)
}
@Test
fun serverApproval_usesChoiceAndAllResultPayload() {
val responses = mutableListOf<Pair<String, JsonElement>>()
val serverDelegate =
ChatApprovalsDelegate(
scope = testScope,
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { "session-1" },
wsSend = { _, _, _ -> error("new approval must not use legacy response") },
trackRequest = { _, _ -> },
addSystemMessage = { },
respondToServerRequest = { id, result -> responses += id to result },
)
serverDelegate.handleApprovalRequest(
WsEvent.ApprovalRequest(
command = "rm -rf /tmp/x",
description = "dangerous",
patternKeys = null,
sessionId = "session-1",
requestId = "approval-1",
serverRequestId = "srq-approval",
),
)
serverDelegate.respondToApproval("approve")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("srq-approval", responses.single().first)
assertEquals(
"once",
responses
.single()
.second.jsonObject["choice"]
?.jsonPrimitive
?.content,
)
assertEquals(
"false",
responses
.single()
.second.jsonObject["all"]
?.jsonPrimitive
?.content,
)
}
}
@@ -0,0 +1,176 @@
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.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChatPromptProtocolTest {
private val dispatcher = StandardTestDispatcher()
@Test
fun clarifyServerResponse_mergesLockedAnswers_withoutEmptyOverwrite() {
val scope = TestScope(dispatcher)
val uiState =
MutableStateFlow(
ChatUiState(
currentSessionId = "session-1",
clarifyRequest =
ClarifyUi(
text = "",
questions =
listOf(
ClarifyQuestionUi(qid = "q0", question = "First?"),
ClarifyQuestionUi(qid = "q1", question = "Second?"),
),
serverRequestId = "srq-clarify",
lockedAnswers = mapOf("q0" to "yes"),
),
),
)
var responseId: String? = null
var response: JsonElement? = null
val delegate =
ChatClarifyDelegate(
uiState = uiState,
scope = scope,
ioDispatcher = dispatcher,
persistMessage = { _, _ -> },
respondToServerRequest = { id, result ->
responseId = id
response = result
},
)
delegate.respondToClarifyBatch(mapOf("q0" to "", "q1" to "new answer"))
dispatcher.scheduler.advanceUntilIdle()
assertEquals("srq-clarify", responseId)
val answers = response?.jsonObject?.get("answers")?.jsonObject
assertNotNull(answers)
assertEquals("yes", answers?.get("q0")?.jsonPrimitive?.content)
assertEquals("new answer", answers?.get("q1")?.jsonPrimitive?.content)
}
@Test
fun credentialPrompt_serverRequestId_controls_response_and_cancellation() {
val scope = TestScope(dispatcher)
val uiState = MutableStateFlow(ChatUiState(currentSessionId = "session-1"))
val sentMethods = mutableListOf<String>()
var responseId: String? = null
var response: JsonElement? = null
val delegate =
ChatCredentialPromptsDelegate(
scope = scope,
ioDispatcher = dispatcher,
uiState = uiState,
wsSend = { method, _, _ -> sentMethods += method },
trackRequest = { _, _ -> },
respondToServerRequest = { id, result ->
responseId = id
response = result
},
)
delegate.handleSudoRequest(
WsEvent.SudoRequest(
requestId = "legacy-sudo-id",
sessionId = "session-1",
serverRequestId = "srq-sudo",
),
)
delegate.handleSudoExpire(WsEvent.SudoExpire(null, "session-1", serverRequestId = "srq-other"))
assertNotNull(uiState.value.sudoPrompt)
delegate.respondToSudo("password")
dispatcher.scheduler.advanceUntilIdle()
assertEquals("srq-sudo", responseId)
assertEquals(
"password",
response
?.jsonObject
?.get("value")
?.jsonPrimitive
?.content,
)
assertNull(uiState.value.sudoPrompt)
assertEquals(emptyList<String>(), sentMethods)
delegate.handleSudoRequest(WsEvent.SudoRequest("legacy-sudo-2", "session-1"))
delegate.respondToSudo("legacy-password")
dispatcher.scheduler.advanceUntilIdle()
assertEquals(listOf(WsMethods.SUDO_RESPOND), sentMethods)
}
@Test
fun secretAndVaultServerRequests_useValueResultPayloads() {
val scope = TestScope(dispatcher)
val uiState = MutableStateFlow(ChatUiState(currentSessionId = "session-1"))
val responses = mutableListOf<Pair<String, JsonElement>>()
val delegate =
ChatCredentialPromptsDelegate(
scope = scope,
ioDispatcher = dispatcher,
uiState = uiState,
wsSend = { _, _, _ -> error("new prompt must not use legacy response") },
trackRequest = { _, _ -> },
respondToServerRequest = { id, result -> responses += id to result },
)
delegate.handleSecretRequest(WsEvent.SecretRequest(null, "session-1", serverRequestId = "srq-secret"))
delegate.respondToSecret("secret-value")
delegate.handleVaultCodeRequest(WsEvent.VaultCodeRequest(null, "session-1", serverRequestId = "srq-code"))
delegate.respondToVaultCode("123456")
delegate.handleVaultUnlockRequest(
WsEvent.VaultUnlockRequest(null, "session-1", serverRequestId = "srq-unlock"),
)
delegate.respondToVaultUnlock("vault-password")
delegate.handleVaultSaveLoginRequest(
WsEvent.VaultSaveLoginRequest(null, "session-1", serverRequestId = "srq-save"),
)
delegate.respondToVaultSaveLogin("alice", "login-password")
dispatcher.scheduler.advanceUntilIdle()
assertEquals(listOf("srq-secret", "srq-code", "srq-unlock", "srq-save"), responses.map { it.first })
assertEquals(
"secret-value",
responses[0]
.second.jsonObject["value"]
?.jsonPrimitive
?.content,
)
assertEquals(
"123456",
responses[1]
.second.jsonObject["value"]
?.jsonPrimitive
?.content,
)
assertEquals(
"vault-password",
responses[2]
.second.jsonObject["value"]
?.jsonPrimitive
?.content,
)
assertEquals(
"{\"identifier\":\"alice\",\"password\":\"login-password\"}",
responses[3]
.second.jsonObject["value"]
?.jsonPrimitive
?.content,
)
}
}
@@ -49,6 +49,8 @@ import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import org.junit.After
import org.junit.Assert.assertArrayEquals
@@ -188,6 +190,8 @@ class ChatViewModelTest {
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
every { HermesWsClient.respondToServerRequest(any(), any()) } returns true
every { HermesWsClient.respondToServerRequestError(any(), any(), any()) } returns true
// Stub model-options so preloadModelOptions() (fired at GatewayReady) is safe.
val mockApi = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
@@ -1494,6 +1498,100 @@ class ChatViewModelTest {
}
}
@Test
fun testServerRequestClarifyReplay_restoresLockedAnswersAndMergesRemainingAnswer() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
mockEventsFlow.emit(
WsEvent.ServerRequest(
id = "srq-clarify-replay",
method = "clarify",
params =
mapOf(
"session_id" to sessionId,
"questions" to
listOf(
mapOf("qid" to "q0", "question" to "First?", "choices" to listOf("yes")),
mapOf("qid" to "q1", "question" to "Second?", "choices" to emptyList<String>()),
),
"answers" to mapOf("q0" to "yes", "invalid" to 42),
),
replayed = true,
),
)
advanceUntilIdle()
val clarify = viewModel.uiState.value.clarifyRequest
assertEquals("srq-clarify-replay", clarify?.serverRequestId)
assertEquals(mapOf("q0" to "yes"), clarify?.lockedAnswers)
assertEquals(2, clarify?.resolvedQuestions?.size)
viewModel.respondToClarifyBatch(mapOf("q0" to "", "q1" to "new answer"))
advanceUntilIdle()
verify {
HermesWsClient.respondToServerRequest(
"srq-clarify-replay",
withArg { result ->
val answers = result.jsonObject["answers"]?.jsonObject
assertEquals("yes", answers?.get("q0")?.jsonPrimitive?.content)
assertEquals("new answer", answers?.get("q1")?.jsonPrimitive?.content)
assertFalse(answers?.containsKey("invalid") == true)
},
)
}
}
@Test
fun serverRequestCancel_clearsOnlyMatchingClarifyPrompt() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
mockEventsFlow.emit(
WsEvent.ServerRequest(
id = "srq-clarify-cancel",
method = "clarify",
params =
mapOf(
"session_id" to sessionId,
"question" to "Continue?",
),
),
)
advanceUntilIdle()
assertEquals(
"srq-clarify-cancel",
viewModel.uiState.value.clarifyRequest
?.serverRequestId,
)
mockEventsFlow.emit(
WsEvent.ServerRequestCancelled(
id = "srq-other",
method = "clarify",
reason = "timeout",
sessionId = sessionId,
),
)
advanceUntilIdle()
assertEquals(
"srq-clarify-cancel",
viewModel.uiState.value.clarifyRequest
?.serverRequestId,
)
mockEventsFlow.emit(
WsEvent.ServerRequestCancelled(
id = "srq-clarify-cancel",
method = "clarify",
reason = "timeout",
sessionId = sessionId,
),
)
advanceUntilIdle()
assertNull(viewModel.uiState.value.clarifyRequest)
}
@Test
fun testClarifyRequestCustomResponse() =
runTest {