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 {
|
||||
|
||||
Reference in new issue
Block a user