feat(chat): full approval.request support (#1057)

This commit is contained in:
M57 authored and GitHub committed 2026-09-03 22:33:15 +03:00
1 parent 077ad1fa81
commit bd0b4c2d55
9 files changed
+630 -49

No files matched your search

@@ -400,6 +400,114 @@ class EventParserTest {
assertEquals("req-expire-1", expireEvent.clarifyId)
}
// ── Approval requests (full support) ───────────────────────────────
@Test
fun testParseApprovalRequest_withChoicesAndRequestId() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "approval.request",
"payload" to
mapOf(
"command" to "rm -rf /tmp/x",
"description" to "dangerous command",
"request_id" to "req-1",
"choices" to listOf("once", "session", "always", "deny"),
"allow_permanent" to true,
"pattern_keys" to listOf("shell:rm"),
),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ApprovalRequest)
val approval = event as WsEvent.ApprovalRequest
assertEquals("rm -rf /tmp/x", approval.command)
assertEquals("req-1", approval.requestId)
assertEquals(listOf("once", "session", "always", "deny"), approval.choices)
assertEquals(true, approval.allowPermanent)
assertEquals(listOf("shell:rm"), approval.patternKeys)
}
@Test
fun testParseApprovalRequest_smartDeniedDefaultsToOnceDeny() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "approval.request",
"payload" to
mapOf(
"command" to "rm -rf /",
"smart_denied" to true,
),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ApprovalRequest)
val approval = event as WsEvent.ApprovalRequest
assertEquals(listOf("once", "deny"), approval.choices)
assertEquals(true, approval.smartDenied)
}
@Test
fun testParseApprovalRequest_legacyDefaultsToFullChoices() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "approval.request",
"payload" to mapOf("command" to "ls"),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ApprovalRequest)
val approval = event as WsEvent.ApprovalRequest
assertEquals(listOf("once", "session", "always", "deny"), approval.choices)
}
@Test
fun testParseApprovalRequest_allowPermanentFalseStillParses() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "approval.request",
"payload" to
mapOf(
"command" to "rm",
"choices" to listOf("once", "session", "always", "deny"),
"allow_permanent" to false,
),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.ApprovalRequest)
val approval = event as WsEvent.ApprovalRequest
assertEquals(false, approval.allowPermanent)
}
// ── TEST-07: Untested subtypes ─────────────────────────────────────
@Test
@@ -3035,6 +3035,167 @@ class ChatViewModelTest {
assertNull(msgAfter!!.approvalInfo)
}
@Test
fun testApprovalRequest_storesRequestIdAndChoices() =
runTest {
val viewModel = createViewModel()
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.ApprovalRequest(
command = "rm -rf /tmp/x",
description = "dangerous command",
patternKeys = listOf("shell:rm"),
sessionId = null,
requestId = "req-1",
choices = listOf("once", "session", "always", "deny"),
allowPermanent = true,
smartDenied = false,
),
)
advanceUntilIdle()
val msg =
viewModel.uiState.value.messages
.first { it.content.contains("Approval Required") }
assertEquals("req-1", msg.approvalInfo?.requestId)
assertEquals(
listOf("once", "session", "always", "deny"),
msg.approvalInfo?.choices,
)
assertEquals(true, msg.approvalInfo?.allowPermanent)
}
@Test
fun testRespondToApproval_mapsApproveToOnceAndSendsRequestId() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.ApprovalRequest(
command = "rm",
description = "Dangerous",
patternKeys = null,
sessionId = null,
requestId = "req-42",
choices = listOf("once", "deny"),
),
)
advanceUntilIdle()
viewModel.respondToApproval("approve")
advanceUntilIdle()
verify {
HermesWsClient.send(
WsMethods.APPROVAL_RESPOND,
withArg { params ->
assertEquals(sessionId, params["session_id"])
assertEquals("once", params["choice"])
assertEquals("req-42", params["request_id"])
},
any(),
)
}
}
@Test
fun testRespondToApproval_sessionChoice() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.ApprovalRequest(
command = "rm",
description = "Dangerous",
patternKeys = null,
sessionId = null,
requestId = "req-7",
choices = listOf("once", "session", "always", "deny"),
),
)
advanceUntilIdle()
viewModel.respondToApproval("session")
advanceUntilIdle()
verify {
HermesWsClient.send(
WsMethods.APPROVAL_RESPOND,
withArg { params ->
assertEquals("session", params["choice"])
assertEquals("req-7", params["request_id"])
},
any(),
)
}
}
@Test
fun testRespondToApproval_omitsRequestIdWhenAbsent() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.ApprovalRequest(
command = "ls",
description = "Safe",
patternKeys = null,
sessionId = null,
),
)
advanceUntilIdle()
viewModel.respondToApproval("deny")
advanceUntilIdle()
verify {
HermesWsClient.send(
WsMethods.APPROVAL_RESPOND,
withArg { params ->
assertEquals("deny", params["choice"])
assertFalse(params.containsKey("request_id"))
},
any(),
)
}
}
@Test
fun testRespondToApproval_triggersPendingReplay() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.ApprovalRequest(
command = "rm",
description = "Dangerous",
patternKeys = null,
sessionId = null,
requestId = "req-9",
choices = listOf("once", "deny"),
),
)
advanceUntilIdle()
viewModel.respondToApproval("once")
advanceUntilIdle()
verify {
HermesWsClient.send(
WsMethods.APPROVAL_PENDING,
withArg { params ->
assertEquals(sessionId, params["session_id"])
},
any(),
)
}
}
// ── Sudo / secret prompt flow (issue #524) ───────────────────────────
@Test