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

@@ -59,6 +59,57 @@ class EventParserTest {
assertEquals(mapOf("status" to "success"), rpcResult.result)
}
@Test
fun testParseServerRequest_withIdAndMethod_isNotRpcResult() {
val event =
EventParser.parse(
createJsonRpcResponse(
jsonrpc = "2.0",
id = "srq-123",
method = "clarify",
params = mapOf("session_id" to "session-1", "question" to "Continue?"),
),
)
assertTrue(event is WsEvent.ServerRequest)
assertFalse(event is WsEvent.RpcResult)
val request = event as WsEvent.ServerRequest
assertEquals("srq-123", request.id)
assertEquals("clarify", request.method)
assertEquals("session-1", request.params["session_id"])
assertEquals("Continue?", request.params["question"])
}
@Test
fun testParseRequestCancel_notification_returnsCancellationEvent() {
val event =
EventParser.parse(
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
method = "event",
params =
mapOf(
"type" to "request.cancel",
"session_id" to "session-1",
"payload" to
mapOf(
"id" to "srq-123",
"method" to "clarify",
"reason" to "timeout",
),
),
),
)
assertTrue(event is WsEvent.ServerRequestCancelled)
val cancelled = event as WsEvent.ServerRequestCancelled
assertEquals("srq-123", cancelled.id)
assertEquals("clarify", cancelled.method)
assertEquals("timeout", cancelled.reason)
assertEquals("session-1", cancelled.sessionId)
}
@Test
fun testParseRpcError_returnsRpcErrorEvent() {
val error = JsonRpcError(code = -32600, message = "Invalid Request")
@@ -19,13 +19,18 @@ import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
@@ -164,6 +169,106 @@ class HermesWsClientTest {
assertTrue(msg.contains(id))
}
@Test
fun testOpenRequestsReplayUsesTheLiveServerRequestDispatcher() =
runBlocking {
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: okhttp3.Response,
) {
webSocket.send(
"""{"jsonrpc":"2.0","id":"42","result":{"open_requests":[{"id":"srq-secret","method":"secret","params":{"session_id":"session-1","env_var":"API_KEY","prompt":"Enter API key"}}]}}""",
)
}
},
),
)
val received =
async {
withTimeout(5_000) {
HermesWsClient.events.first {
it is WsEvent.ServerRequest && it.id == "srq-secret"
}
}
}
HermesWsClient.connect()
val event = received.await() as WsEvent.ServerRequest
assertEquals("secret", event.method)
assertEquals("session-1", event.params["session_id"])
assertEquals("API_KEY", event.params["env_var"])
}
@Test
fun testRespondToServerRequest_preservesIdAndOmitsMethod() {
val socket = mockk<WebSocket>()
every { socket.send(any<String>()) } returns true
every { socket.close(any(), any()) } returns true
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true }
val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true }
socketField.set(HermesWsClient, socket)
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
val sent =
HermesWsClient.respondToServerRequest(
"srq-abc123",
buildJsonObject { put("value", "hello") },
)
assertTrue(sent)
verify {
socket.send(
match<String> { raw ->
val frame = Json.parseToJsonElement(raw).jsonObject
frame["jsonrpc"]?.jsonPrimitive?.content == "2.0" &&
frame["id"]?.jsonPrimitive?.content == "srq-abc123" &&
frame["result"]
?.jsonObject
?.get("value")
?.jsonPrimitive
?.content == "hello" &&
!frame.containsKey("method")
},
)
}
}
@Test
fun testRespondToServerRequestError_preservesIdAndErrorShape() {
val socket = mockk<WebSocket>()
every { socket.send(any<String>()) } returns true
every { socket.close(any(), any()) } returns true
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true }
val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true }
socketField.set(HermesWsClient, socket)
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
assertTrue(HermesWsClient.respondToServerRequestError("srq-abc123", -32601, "unsupported"))
verify {
socket.send(
match<String> { raw ->
val frame = Json.parseToJsonElement(raw).jsonObject
frame["id"]?.jsonPrimitive?.content == "srq-abc123" &&
frame["error"]
?.jsonObject
?.get("code")
?.jsonPrimitive
?.content == "-32601" &&
frame["error"]
?.jsonObject
?.get("message")
?.jsonPrimitive
?.content == "unsupported" &&
!frame.containsKey("method")
},
)
}
}
@Test
fun testFailedSocketSendQueuesMessageForReconnect() {
val staleSocket = mockk<WebSocket>(relaxed = true)
@@ -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 {