16 files changed
+1169
-119
No files matched your search
@@ -19,9 +19,17 @@ object EventParser {
|
||||
response: JsonRpcResponse,
|
||||
rawJson: String = "",
|
||||
): WsEvent {
|
||||
// ── RPC response (has id) ────────────────────────────────────────
|
||||
// JSON-RPC is peer-to-peer: an inbound frame with both `id` and `method`
|
||||
// is a request from the gateway, not a response to one of our calls.
|
||||
val id = response.id
|
||||
if (id != null) {
|
||||
if (response.method != null && id != null) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val requestParams = response.params?.toAny() as? Map<String, Any?> ?: emptyMap()
|
||||
return WsEvent.ServerRequest(id, response.method, requestParams)
|
||||
}
|
||||
|
||||
// A response has no method and carries a result or error member.
|
||||
if (response.method == null && id != null && (response.result != null || response.error != null)) {
|
||||
return if (response.error != null) {
|
||||
WsEvent.RpcError(id, response.error)
|
||||
} else {
|
||||
@@ -220,6 +228,13 @@ object EventParser {
|
||||
WsEvent.ClarifyExpire(clarifyId, sessionId)
|
||||
}
|
||||
|
||||
"request.cancel" -> {
|
||||
val requestId = payload?.get("id") as? String ?: ""
|
||||
val requestMethod = payload?.get("method") as? String ?: ""
|
||||
val reason = payload?.get("reason") as? String ?: ""
|
||||
WsEvent.ServerRequestCancelled(requestId, requestMethod, reason, sessionId)
|
||||
}
|
||||
|
||||
"status.update" -> {
|
||||
val status = payload?.get("status") as? String
|
||||
WsEvent.StatusUpdate(status, payload)
|
||||
|
||||
@@ -33,6 +33,8 @@ import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
@@ -808,6 +810,55 @@ object HermesWsClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer a gateway-originated JSON-RPC request. This deliberately bypasses
|
||||
* [send]: server requests already have an id allocated by the gateway and
|
||||
* responses must not contain a method or receive a new client id.
|
||||
*/
|
||||
fun respondToServerRequest(
|
||||
id: String,
|
||||
result: JsonElement,
|
||||
): Boolean {
|
||||
if (id.isBlank()) return false
|
||||
val json =
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", id)
|
||||
put("result", result)
|
||||
}.toString()
|
||||
synchronized(outboundLock) {
|
||||
val ws = webSocket
|
||||
if (ws == null || !connected.get()) return false
|
||||
return ws.send(json)
|
||||
}
|
||||
}
|
||||
|
||||
/** Answer a gateway-originated request with a standard JSON-RPC error. */
|
||||
fun respondToServerRequestError(
|
||||
id: String,
|
||||
code: Int,
|
||||
message: String,
|
||||
): Boolean {
|
||||
if (id.isBlank()) return false
|
||||
val json =
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", id)
|
||||
put(
|
||||
"error",
|
||||
buildJsonObject {
|
||||
put("code", code)
|
||||
put("message", message)
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
synchronized(outboundLock) {
|
||||
val ws = webSocket
|
||||
if (ws == null || !connected.get()) return false
|
||||
return ws.send(json)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Send helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1249,6 +1300,21 @@ object HermesWsClient {
|
||||
resolvePending(rpcId, rpc.result, null)
|
||||
}
|
||||
|
||||
// Resume/replay responses carry still-open server requests
|
||||
// separately from the event ring. Re-deliver them through the
|
||||
// same generic path before exposing the RPC result.
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val openRequests =
|
||||
(rpc.result?.toAny() as? Map<*, *>)?.get("open_requests") as? List<Map<*, *>>
|
||||
openRequests?.forEach { openRequest ->
|
||||
val openId = openRequest["id"] as? String ?: return@forEach
|
||||
val method = openRequest["method"] as? String ?: return@forEach
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val params = openRequest["params"] as? Map<String, Any?> ?: emptyMap()
|
||||
parsedEvents.tryEmit(WsEvent.ServerRequest(openId, method, params, replayed = true))
|
||||
}
|
||||
|
||||
if (rpc.id == null) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val params = rpc.params?.toAny() as? Map<String, Any?>
|
||||
|
||||
@@ -23,7 +23,9 @@ data class JsonRpcRequest(
|
||||
)
|
||||
|
||||
/**
|
||||
* Incoming JSON-RPC 2.0 response **or** server-pushed notification.
|
||||
* Incoming JSON-RPC 2.0 response, server-to-client request, or notification.
|
||||
* Frames carrying both [id] and [method] are classified as server requests by
|
||||
* [EventParser], never as responses to a client RPC.
|
||||
*/
|
||||
@Serializable
|
||||
data class JsonRpcResponse(
|
||||
|
||||
@@ -152,6 +152,8 @@ sealed class WsEvent {
|
||||
val questionId: String? = null,
|
||||
val multiSelect: Boolean = false,
|
||||
val questions: List<ClarifyQuestion> = emptyList(),
|
||||
val serverRequestId: String? = null,
|
||||
val lockedAnswers: Map<String, String> = emptyMap(),
|
||||
) : WsEvent()
|
||||
|
||||
data class ClarifyExpire(
|
||||
@@ -213,6 +215,25 @@ sealed class WsEvent {
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
/**
|
||||
* Generic JSON-RPC server-to-client request. The gateway keeps the request
|
||||
* open until the client sends a response frame with this exact [id].
|
||||
*/
|
||||
data class ServerRequest(
|
||||
val id: String,
|
||||
val method: String,
|
||||
val params: Map<String, Any?> = emptyMap(),
|
||||
val replayed: Boolean = false,
|
||||
) : WsEvent()
|
||||
|
||||
/** The gateway withdrew a still-open server request (timeout/interruption). */
|
||||
data class ServerRequestCancelled(
|
||||
val id: String,
|
||||
val method: String,
|
||||
val reason: String,
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
// ── RPC responses ────────────────────────────────────────────────────
|
||||
|
||||
data class RpcResult(
|
||||
@@ -256,18 +277,21 @@ sealed class WsEvent {
|
||||
* restricts to once/deny only (desktop `smartDenied` parity).
|
||||
*/
|
||||
val smartDenied: Boolean? = null,
|
||||
/** JSON-RPC server-request id (`srq-*`) used for the response frame. */
|
||||
val serverRequestId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Sudo / secret requests ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Backend needs the user's sudo password to continue a turn
|
||||
* (desktop: `sudo.request` → `sudo.respond {request_id, password}`).
|
||||
* Mobile previously dropped this and the agent hung forever.
|
||||
* New gateways use a same-ID JSON-RPC result frame; legacy gateways fall
|
||||
* back to `sudo.respond`.
|
||||
*/
|
||||
data class SudoRequest(
|
||||
val requestId: String?,
|
||||
val sessionId: String?,
|
||||
val serverRequestId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
/**
|
||||
@@ -278,12 +302,13 @@ sealed class WsEvent {
|
||||
data class SudoExpire(
|
||||
val requestId: String?,
|
||||
val sessionId: String?,
|
||||
val serverRequestId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
/**
|
||||
* Backend needs a secret value (password / token) to continue a turn
|
||||
* (desktop: `secret.request` → `secret.respond {request_id, value}`).
|
||||
* Mobile previously dropped this and the agent hung forever.
|
||||
* New gateways use a same-ID JSON-RPC result frame; legacy gateways fall
|
||||
* back to `secret.respond`.
|
||||
* `envVar`/`prompt` mirror desktop: title = envVar ?: secretTitle,
|
||||
* body = prompt ?: secretDesc.
|
||||
*/
|
||||
@@ -292,6 +317,7 @@ sealed class WsEvent {
|
||||
val sessionId: String?,
|
||||
val envVar: String? = null,
|
||||
val prompt: String? = null,
|
||||
val serverRequestId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
/**
|
||||
@@ -301,6 +327,7 @@ sealed class WsEvent {
|
||||
data class SecretExpire(
|
||||
val requestId: String?,
|
||||
val sessionId: String?,
|
||||
val serverRequestId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Credential Vault prompts (issue #1090) ──────────────────────────
|
||||
@@ -310,11 +337,13 @@ sealed class WsEvent {
|
||||
val sessionId: String? = null,
|
||||
val backend: String? = null,
|
||||
val displayName: String? = null,
|
||||
val serverRequestId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultUnlockExpire(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
val serverRequestId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultSaveLoginRequest(
|
||||
@@ -322,11 +351,13 @@ sealed class WsEvent {
|
||||
val sessionId: String? = null,
|
||||
val origin: String? = null,
|
||||
val site: String? = null,
|
||||
val serverRequestId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultSaveLoginExpire(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
val serverRequestId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultCodeRequest(
|
||||
@@ -334,11 +365,13 @@ sealed class WsEvent {
|
||||
val sessionId: String? = null,
|
||||
val site: String? = null,
|
||||
val hint: String? = null,
|
||||
val serverRequestId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class VaultCodeExpire(
|
||||
val requestId: String?,
|
||||
val sessionId: String? = null,
|
||||
val serverRequestId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Gateway-level errors ───────────────────────────────────────────
|
||||
|
||||
@@ -7,6 +7,9 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Owns approval rendering, deduplication, user response submission, and
|
||||
@@ -22,8 +25,49 @@ class ChatApprovalsDelegate(
|
||||
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
|
||||
private val trackRequest: (id: String, method: String) -> Unit,
|
||||
private val addSystemMessage: (text: String) -> Unit,
|
||||
private val respondToServerRequest: ((String, JsonElement) -> Unit)? = null,
|
||||
) {
|
||||
fun handleApprovalRequest(event: WsEvent.ApprovalRequest) {
|
||||
val existingIndex =
|
||||
uiState.value.messages.indexOfLast { message ->
|
||||
val approval = message.approvalInfo ?: return@indexOfLast false
|
||||
(event.serverRequestId != null && approval.serverRequestId == event.serverRequestId) ||
|
||||
(event.requestId != null && approval.requestId == event.requestId)
|
||||
}
|
||||
if (existingIndex >= 0) {
|
||||
// `approval.pending` can arrive before the live server request during
|
||||
// reconnect. Upgrade that legacy card with the direct server id so
|
||||
// the user's next action answers the same JSON-RPC request instead of
|
||||
// creating a duplicate prompt.
|
||||
if (event.serverRequestId != null) {
|
||||
uiState.update { state ->
|
||||
state.copy(
|
||||
messages =
|
||||
state.messages.mapIndexed { index, message ->
|
||||
if (index != existingIndex) {
|
||||
message
|
||||
} else {
|
||||
val approval = message.approvalInfo
|
||||
message.copy(
|
||||
approvalInfo =
|
||||
approval?.copy(
|
||||
command = event.command ?: approval.command,
|
||||
description = event.description ?: approval.description,
|
||||
patternKeys = event.patternKeys ?: approval.patternKeys,
|
||||
requestId = event.requestId ?: approval.requestId,
|
||||
serverRequestId = event.serverRequestId,
|
||||
choices = event.choices ?: approval.choices,
|
||||
allowPermanent = event.allowPermanent ?: approval.allowPermanent,
|
||||
smartDenied = event.smartDenied ?: approval.smartDenied,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
val description = event.description ?: event.command ?: "Unknown command"
|
||||
val content = "**Approval Required**\n$description"
|
||||
val msg =
|
||||
@@ -36,6 +80,7 @@ class ChatApprovalsDelegate(
|
||||
description = event.description,
|
||||
patternKeys = event.patternKeys,
|
||||
requestId = event.requestId,
|
||||
serverRequestId = event.serverRequestId,
|
||||
choices = event.choices,
|
||||
allowPermanent = event.allowPermanent,
|
||||
smartDenied = event.smartDenied,
|
||||
@@ -51,7 +96,7 @@ class ChatApprovalsDelegate(
|
||||
// so the backend knows this client holds the prompt. Fire-and-forget.
|
||||
val requestId = event.requestId
|
||||
val sessionId = runtimeSessionId() ?: event.sessionId ?: uiState.value.currentSessionId
|
||||
if (requestId != null && sessionId != null) {
|
||||
if (event.serverRequestId == null && requestId != null && sessionId != null) {
|
||||
scope.launch(ioDispatcher) {
|
||||
runCatching {
|
||||
wsSend(
|
||||
@@ -118,6 +163,7 @@ class ChatApprovalsDelegate(
|
||||
// (any non-deny still unblocks, but stay on-spec going forward).
|
||||
val choice = if (action == "approve") "once" else action
|
||||
val requestId = approvalMsg.approvalInfo?.requestId
|
||||
val serverRequestId = approvalMsg.approvalInfo?.serverRequestId
|
||||
|
||||
// Clear buttons immediately
|
||||
uiState.update { s ->
|
||||
@@ -134,6 +180,16 @@ class ChatApprovalsDelegate(
|
||||
}
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
if (serverRequestId != null && respondToServerRequest != null) {
|
||||
respondToServerRequest.invoke(
|
||||
serverRequestId,
|
||||
buildJsonObject {
|
||||
put("choice", choice)
|
||||
put("all", false)
|
||||
},
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to sessionId,
|
||||
@@ -149,6 +205,21 @@ class ChatApprovalsDelegate(
|
||||
replayPendingApproval(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelServerRequest(requestId: String) {
|
||||
uiState.update { state ->
|
||||
state.copy(
|
||||
messages =
|
||||
state.messages.map { message ->
|
||||
if (message.approvalInfo?.serverRequestId == requestId) {
|
||||
message.copy(approvalInfo = null)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,6 +260,7 @@ internal fun parseApprovalMap(
|
||||
patternKeys = patternKeys,
|
||||
sessionId = sessionId,
|
||||
requestId = map["request_id"] as? String,
|
||||
serverRequestId = null,
|
||||
choices = choices,
|
||||
allowPermanent = allowPermanent,
|
||||
smartDenied = smartDenied,
|
||||
|
||||
@@ -7,6 +7,9 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
class ChatClarifyDelegate(
|
||||
private val uiState: MutableStateFlow<ChatUiState>,
|
||||
@@ -15,6 +18,7 @@ class ChatClarifyDelegate(
|
||||
private val persistMessage: suspend (ChatMessage, String) -> Unit,
|
||||
private val wsClient: HermesWsClient = HermesWsClient,
|
||||
private val trackRequest: (String, String) -> Unit = { _, _ -> },
|
||||
private val respondToServerRequest: ((String, JsonElement) -> Unit)? = null,
|
||||
) {
|
||||
fun respondToClarify(option: String) {
|
||||
val clarify = uiState.value.clarifyRequest
|
||||
@@ -41,6 +45,8 @@ class ChatClarifyDelegate(
|
||||
val sessionId = uiState.value.currentSessionId ?: return
|
||||
val clarify = uiState.value.clarifyRequest
|
||||
val clarifyId = clarify?.clarifyId
|
||||
val serverRequestId = clarify?.serverRequestId
|
||||
val lockedAnswers = clarify?.lockedAnswers.orEmpty()
|
||||
val isBatch = !clarify?.questions.isNullOrEmpty()
|
||||
val questions = clarify?.resolvedQuestions.orEmpty()
|
||||
uiState.update { it.copy(clarifyRequest = null) }
|
||||
@@ -49,7 +55,9 @@ class ChatClarifyDelegate(
|
||||
if (questions.size > 1) {
|
||||
questions
|
||||
.mapIndexed { index, q ->
|
||||
val ans = answers[q.qid]?.trim().orEmpty()
|
||||
val ans =
|
||||
answers[q.qid]?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: lockedAnswers[q.qid].orEmpty()
|
||||
"${index + 1}. ${ans.ifEmpty { "(Skipped)" }}"
|
||||
}.joinToString("\n")
|
||||
} else {
|
||||
@@ -75,6 +83,35 @@ class ChatClarifyDelegate(
|
||||
}
|
||||
|
||||
scope.launch(ioDispatcher) {
|
||||
if (serverRequestId != null && respondToServerRequest != null) {
|
||||
val finalAnswers =
|
||||
buildMap {
|
||||
putAll(lockedAnswers)
|
||||
answers.forEach { (qid, answer) ->
|
||||
answer.trim().takeIf { it.isNotEmpty() }?.let { put(qid, it) }
|
||||
}
|
||||
}
|
||||
val result =
|
||||
if (isBatch) {
|
||||
buildJsonObject {
|
||||
put(
|
||||
"answers",
|
||||
buildJsonObject {
|
||||
for ((qid, answer) in finalAnswers) {
|
||||
put(qid, answer)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
buildJsonObject {
|
||||
put("answer", answers.values.firstOrNull()?.trim() ?: singleFallbackAnswer.orEmpty())
|
||||
}
|
||||
}
|
||||
respondToServerRequest.invoke(serverRequestId, result)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (isBatch) {
|
||||
for (q in questions) {
|
||||
val ans = answers[q.qid]?.trim().orEmpty()
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
@@ -21,15 +22,29 @@ class ChatCredentialPromptsDelegate(
|
||||
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,
|
||||
private val respondToServerRequest: ((String, JsonElement) -> Unit)? = null,
|
||||
) {
|
||||
private fun sendResponse(
|
||||
serverRequestId: String?,
|
||||
result: JsonElement,
|
||||
legacyMethod: String,
|
||||
legacyParams: Map<String, Any>,
|
||||
) {
|
||||
if (serverRequestId != null && respondToServerRequest != null) {
|
||||
respondToServerRequest.invoke(serverRequestId, result)
|
||||
} else {
|
||||
wsSend(legacyMethod, legacyParams) { id -> trackRequest(id, legacyMethod) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent needs the user's sudo password. Surface a secure dialog and
|
||||
* reply via sudo.respond.
|
||||
* New gateways use a same-ID JSON-RPC response; legacy notifications fall
|
||||
* back to sudo.respond.
|
||||
*/
|
||||
fun handleSudoRequest(event: WsEvent.SudoRequest) {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
sudoPrompt = SudoPromptUi(event.requestId, event.sessionId),
|
||||
sudoPrompt = SudoPromptUi(event.requestId, event.sessionId, event.serverRequestId),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
}
|
||||
@@ -42,9 +57,10 @@ class ChatCredentialPromptsDelegate(
|
||||
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
|
||||
) {
|
||||
if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) {
|
||||
return@update state
|
||||
}
|
||||
if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(sudoPrompt = null)
|
||||
@@ -52,8 +68,8 @@ class ChatCredentialPromptsDelegate(
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent needs a secret value (token/password). Surface a secure dialog
|
||||
* and reply via secret.respond.
|
||||
* New gateways use a same-ID JSON-RPC response; legacy notifications fall
|
||||
* back to secret.respond.
|
||||
*/
|
||||
fun handleSecretRequest(event: WsEvent.SecretRequest) {
|
||||
uiState.update {
|
||||
@@ -64,6 +80,7 @@ class ChatCredentialPromptsDelegate(
|
||||
event.sessionId,
|
||||
event.envVar,
|
||||
event.prompt,
|
||||
event.serverRequestId,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
@@ -76,9 +93,10 @@ class ChatCredentialPromptsDelegate(
|
||||
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
|
||||
) {
|
||||
if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) {
|
||||
return@update state
|
||||
}
|
||||
if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(secretPrompt = null)
|
||||
@@ -101,10 +119,12 @@ class ChatCredentialPromptsDelegate(
|
||||
"password" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
sendResponse(
|
||||
prompt.serverRequestId,
|
||||
buildJsonObject { put("value", "") },
|
||||
WsMethods.SUDO_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,10 +144,12 @@ class ChatCredentialPromptsDelegate(
|
||||
"value" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
sendResponse(
|
||||
prompt.serverRequestId,
|
||||
buildJsonObject { put("value", "") },
|
||||
WsMethods.SECRET_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,10 +171,12 @@ class ChatCredentialPromptsDelegate(
|
||||
"password" to password,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
sendResponse(
|
||||
prompt.serverRequestId,
|
||||
buildJsonObject { put("value", password) },
|
||||
WsMethods.SUDO_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,10 +197,12 @@ class ChatCredentialPromptsDelegate(
|
||||
"value" to value,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
sendResponse(
|
||||
prompt.serverRequestId,
|
||||
buildJsonObject { put("value", value) },
|
||||
WsMethods.SECRET_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +210,8 @@ class ChatCredentialPromptsDelegate(
|
||||
|
||||
/**
|
||||
* The agent needs to unlock a password manager (1Password, Bitwarden).
|
||||
* Surface an interactive prompt card in chat and reply via vault.unlock.respond.
|
||||
* New gateways use a same-ID JSON-RPC response; legacy notifications fall
|
||||
* back to vault.unlock.respond.
|
||||
*/
|
||||
fun handleVaultUnlockRequest(event: WsEvent.VaultUnlockRequest) {
|
||||
uiState.update {
|
||||
@@ -195,6 +222,7 @@ class ChatCredentialPromptsDelegate(
|
||||
sessionId = event.sessionId,
|
||||
backend = event.backend,
|
||||
displayName = event.displayName,
|
||||
serverRequestId = event.serverRequestId,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
@@ -204,9 +232,10 @@ class ChatCredentialPromptsDelegate(
|
||||
fun handleVaultUnlockExpire(event: WsEvent.VaultUnlockExpire) {
|
||||
uiState.update { state ->
|
||||
val current = state.vaultUnlockPrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) {
|
||||
return@update state
|
||||
}
|
||||
if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(vaultUnlockPrompt = null)
|
||||
@@ -228,10 +257,12 @@ class ChatCredentialPromptsDelegate(
|
||||
"password" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
sendResponse(
|
||||
prompt.serverRequestId,
|
||||
buildJsonObject { put("value", "") },
|
||||
WsMethods.VAULT_UNLOCK_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,10 +280,12 @@ class ChatCredentialPromptsDelegate(
|
||||
"password" to password,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
sendResponse(
|
||||
prompt.serverRequestId,
|
||||
buildJsonObject { put("value", password) },
|
||||
WsMethods.VAULT_UNLOCK_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +301,7 @@ class ChatCredentialPromptsDelegate(
|
||||
sessionId = event.sessionId,
|
||||
origin = event.origin,
|
||||
site = event.site,
|
||||
serverRequestId = event.serverRequestId,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
@@ -277,9 +311,10 @@ class ChatCredentialPromptsDelegate(
|
||||
fun handleVaultSaveLoginExpire(event: WsEvent.VaultSaveLoginExpire) {
|
||||
uiState.update { state ->
|
||||
val current = state.vaultSaveLoginPrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) {
|
||||
return@update state
|
||||
}
|
||||
if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(vaultSaveLoginPrompt = null)
|
||||
@@ -301,10 +336,12 @@ class ChatCredentialPromptsDelegate(
|
||||
"login" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
sendResponse(
|
||||
prompt.serverRequestId,
|
||||
buildJsonObject { put("value", "") },
|
||||
WsMethods.VAULT_SAVE_LOGIN_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,10 +367,12 @@ class ChatCredentialPromptsDelegate(
|
||||
"login" to loginJson,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
sendResponse(
|
||||
prompt.serverRequestId,
|
||||
buildJsonObject { put("value", loginJson) },
|
||||
WsMethods.VAULT_SAVE_LOGIN_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +388,7 @@ class ChatCredentialPromptsDelegate(
|
||||
sessionId = event.sessionId,
|
||||
site = event.site,
|
||||
hint = event.hint,
|
||||
serverRequestId = event.serverRequestId,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
)
|
||||
@@ -358,9 +398,10 @@ class ChatCredentialPromptsDelegate(
|
||||
fun handleVaultCodeExpire(event: WsEvent.VaultCodeExpire) {
|
||||
uiState.update { state ->
|
||||
val current = state.vaultCodePrompt ?: return@update state
|
||||
if (event.requestId != null && current.requestId != null &&
|
||||
event.requestId != current.requestId
|
||||
) {
|
||||
if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) {
|
||||
return@update state
|
||||
}
|
||||
if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) {
|
||||
return@update state
|
||||
}
|
||||
state.copy(vaultCodePrompt = null)
|
||||
@@ -382,10 +423,12 @@ class ChatCredentialPromptsDelegate(
|
||||
"code" to "",
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
sendResponse(
|
||||
prompt.serverRequestId,
|
||||
buildJsonObject { put("value", "") },
|
||||
WsMethods.VAULT_CODE_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,10 +446,12 @@ class ChatCredentialPromptsDelegate(
|
||||
"code" to code,
|
||||
)
|
||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||
wsSend(
|
||||
sendResponse(
|
||||
prompt.serverRequestId,
|
||||
buildJsonObject { put("value", code) },
|
||||
WsMethods.VAULT_CODE_RESPOND,
|
||||
params,
|
||||
) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ data class ApprovalInfo(
|
||||
val patternKeys: List<String>?,
|
||||
/** Backend `request_id` — sent back in `approval.respond` to pin the exact pending. */
|
||||
val requestId: String? = null,
|
||||
/** JSON-RPC server-request id (`srq-*`) used for the response frame. */
|
||||
val serverRequestId: String? = null,
|
||||
/** Backend-advertised choices (`once`/`session`/`always`/`deny`). Null = legacy Run/Deny. */
|
||||
val choices: List<String>? = null,
|
||||
/** False → hide "Always allow" (tirith warning, desktop parity). */
|
||||
|
||||
@@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
@@ -204,6 +205,8 @@ data class ClarifyUi(
|
||||
val questionId: String? = null,
|
||||
val multiSelect: Boolean = false,
|
||||
val questions: List<ClarifyQuestionUi> = emptyList(),
|
||||
val serverRequestId: String? = null,
|
||||
val lockedAnswers: Map<String, String> = emptyMap(),
|
||||
) {
|
||||
/**
|
||||
* Normalized list of questions to display. Guarantees at least one question
|
||||
@@ -251,6 +254,7 @@ private const val CLARIFY_DISMISS_RESPONSE = "The user cancelled — no answer p
|
||||
data class SudoPromptUi(
|
||||
val requestId: String?,
|
||||
val sessionId: String?,
|
||||
val serverRequestId: String? = null,
|
||||
)
|
||||
|
||||
/** Transient — not persisted. Holds a pending secret (token/password) request. */
|
||||
@@ -259,6 +263,7 @@ data class SecretPromptUi(
|
||||
val sessionId: String?,
|
||||
val envVar: String? = null,
|
||||
val prompt: String? = null,
|
||||
val serverRequestId: String? = null,
|
||||
)
|
||||
|
||||
/** Transient — not persisted. Holds a pending vault unlock request (issue #1090). */
|
||||
@@ -267,6 +272,7 @@ data class VaultUnlockPromptUi(
|
||||
val sessionId: String?,
|
||||
val backend: String? = null,
|
||||
val displayName: String? = null,
|
||||
val serverRequestId: String? = null,
|
||||
)
|
||||
|
||||
/** Transient — not persisted. Holds a pending vault save login request (issue #1090). */
|
||||
@@ -275,6 +281,7 @@ data class VaultSaveLoginPromptUi(
|
||||
val sessionId: String?,
|
||||
val origin: String? = null,
|
||||
val site: String? = null,
|
||||
val serverRequestId: String? = null,
|
||||
)
|
||||
|
||||
/** Transient — not persisted. Holds a pending vault 2FA/MFA code request (issue #1090). */
|
||||
@@ -283,6 +290,7 @@ data class VaultCodePromptUi(
|
||||
val sessionId: String?,
|
||||
val site: String? = null,
|
||||
val hint: String? = null,
|
||||
val serverRequestId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -469,6 +477,7 @@ class ChatViewModel(
|
||||
uiState = _uiState,
|
||||
wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) },
|
||||
trackRequest = { id, method -> trackRequest(id, method) },
|
||||
respondToServerRequest = { id, result -> wsClient.respondToServerRequest(id, result) },
|
||||
)
|
||||
|
||||
private val approvalsDelegate =
|
||||
@@ -480,6 +489,7 @@ class ChatViewModel(
|
||||
wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) },
|
||||
trackRequest = { id, method -> trackRequest(id, method) },
|
||||
addSystemMessage = { text -> addSystemMessage(text) },
|
||||
respondToServerRequest = { id, result -> wsClient.respondToServerRequest(id, result) },
|
||||
)
|
||||
|
||||
private val clarifyDelegate =
|
||||
@@ -490,6 +500,7 @@ class ChatViewModel(
|
||||
persistMessage = { msg, sid -> repo.persistMessage(msg, sid) },
|
||||
wsClient = wsClient,
|
||||
trackRequest = { id, method -> trackRequest(id, method) },
|
||||
respondToServerRequest = { id, result -> wsClient.respondToServerRequest(id, result) },
|
||||
)
|
||||
|
||||
private val subagentsDelegate =
|
||||
@@ -941,6 +952,14 @@ class ChatViewModel(
|
||||
streamingController.resetStreaming()
|
||||
}
|
||||
|
||||
is WsEvent.ServerRequest -> {
|
||||
handleServerRequest(event)
|
||||
}
|
||||
|
||||
is WsEvent.ServerRequestCancelled -> {
|
||||
handleServerRequestCancelled(event)
|
||||
}
|
||||
|
||||
is WsEvent.ApprovalRequest -> {
|
||||
approvalsDelegate.handleApprovalRequest(event)
|
||||
}
|
||||
@@ -1015,6 +1034,184 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Route the generic transport event into the existing prompt delegates. */
|
||||
private fun handleServerRequest(request: WsEvent.ServerRequest) {
|
||||
val params = request.params
|
||||
val sessionId = params["session_id"] as? String
|
||||
when (request.method) {
|
||||
"clarify" -> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val rawQuestions = params["questions"] as? List<*>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val lockedAnswers =
|
||||
(params["answers"] as? Map<*, *>)
|
||||
?.entries
|
||||
?.mapNotNull { (key, value) ->
|
||||
if (key is String && value is String) key to value else null
|
||||
}?.toMap()
|
||||
.orEmpty()
|
||||
val questions =
|
||||
rawQuestions.orEmpty().mapIndexedNotNull { index, item ->
|
||||
val map = item as? Map<*, *> ?: return@mapIndexedNotNull null
|
||||
val question = map["question"] as? String ?: return@mapIndexedNotNull null
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val choices = (map["choices"] as? List<*>)?.filterIsInstance<String>() ?: emptyList()
|
||||
WsEvent.ClarifyQuestion(
|
||||
qid = map["qid"] as? String ?: "q$index",
|
||||
question = question,
|
||||
choices = choices,
|
||||
multiSelect = map["multi_select"] as? Boolean ?: false,
|
||||
)
|
||||
}
|
||||
val first = questions.firstOrNull()
|
||||
handleWsEvent(
|
||||
WsEvent.ClarifyRequest(
|
||||
text = first?.question ?: params["question"] as? String,
|
||||
options = first?.choices ?: (params["choices"] as? List<*>)?.filterIsInstance<String>(),
|
||||
clarifyId = params["clarify_id"] as? String ?: params["request_id"] as? String,
|
||||
sessionId = sessionId,
|
||||
questionId = first?.qid,
|
||||
multiSelect = first?.multiSelect ?: (params["multi_select"] as? Boolean ?: false),
|
||||
questions = questions,
|
||||
serverRequestId = request.id,
|
||||
lockedAnswers = lockedAnswers,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
"approval" -> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val choices = (params["choices"] as? List<*>)?.filterIsInstance<String>()
|
||||
handleWsEvent(
|
||||
WsEvent.ApprovalRequest(
|
||||
command = params["command"] as? String,
|
||||
description = params["description"] as? String,
|
||||
patternKeys = (params["pattern_keys"] as? List<*>)?.filterIsInstance<String>(),
|
||||
sessionId = sessionId,
|
||||
requestId = params["request_id"] as? String,
|
||||
serverRequestId = request.id,
|
||||
choices = choices,
|
||||
allowPermanent = params["allow_permanent"] as? Boolean,
|
||||
smartDenied = params["smart_denied"] as? Boolean,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
"sudo" -> {
|
||||
handleWsEvent(
|
||||
WsEvent.SudoRequest(
|
||||
requestId = params["request_id"] as? String,
|
||||
sessionId = sessionId,
|
||||
serverRequestId = request.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
"secret" -> {
|
||||
handleWsEvent(
|
||||
WsEvent.SecretRequest(
|
||||
requestId = params["request_id"] as? String,
|
||||
sessionId = sessionId,
|
||||
envVar = params["env_var"] as? String,
|
||||
prompt = params["prompt"] as? String,
|
||||
serverRequestId = request.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
"vault.code" -> {
|
||||
handleWsEvent(
|
||||
WsEvent.VaultCodeRequest(
|
||||
requestId = params["request_id"] as? String,
|
||||
sessionId = sessionId,
|
||||
site = params["site"] as? String,
|
||||
hint = params["hint"] as? String,
|
||||
serverRequestId = request.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
"vault.save_login" -> {
|
||||
handleWsEvent(
|
||||
WsEvent.VaultSaveLoginRequest(
|
||||
requestId = params["request_id"] as? String,
|
||||
sessionId = sessionId,
|
||||
origin = params["origin"] as? String,
|
||||
site = params["site"] as? String,
|
||||
serverRequestId = request.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
"vault.unlock_prompt" -> {
|
||||
handleWsEvent(
|
||||
WsEvent.VaultUnlockRequest(
|
||||
requestId = params["request_id"] as? String,
|
||||
sessionId = sessionId,
|
||||
backend = params["backend"] as? String,
|
||||
displayName = params["display_name"] as? String,
|
||||
serverRequestId = request.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
wsClient.respondToServerRequestError(
|
||||
request.id,
|
||||
-32601,
|
||||
"no handler for server request: ${request.method}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleServerRequestCancelled(event: WsEvent.ServerRequestCancelled) {
|
||||
when (event.method) {
|
||||
"clarify" -> {
|
||||
if (_uiState.value.clarifyRequest?.serverRequestId == event.id) {
|
||||
_uiState.update { it.copy(clarifyRequest = null) }
|
||||
streamingController.resetStreaming()
|
||||
}
|
||||
}
|
||||
|
||||
"approval" -> {
|
||||
approvalsDelegate.cancelServerRequest(event.id)
|
||||
}
|
||||
|
||||
"sudo" -> {
|
||||
credentialPromptsDelegate.handleSudoExpire(
|
||||
WsEvent.SudoExpire(null, event.sessionId, serverRequestId = event.id),
|
||||
)
|
||||
}
|
||||
|
||||
"secret" -> {
|
||||
credentialPromptsDelegate.handleSecretExpire(
|
||||
WsEvent.SecretExpire(null, event.sessionId, serverRequestId = event.id),
|
||||
)
|
||||
}
|
||||
|
||||
"vault.code" -> {
|
||||
credentialPromptsDelegate.handleVaultCodeExpire(
|
||||
WsEvent.VaultCodeExpire(null, event.sessionId, serverRequestId = event.id),
|
||||
)
|
||||
}
|
||||
|
||||
"vault.save_login" -> {
|
||||
credentialPromptsDelegate.handleVaultSaveLoginExpire(
|
||||
WsEvent.VaultSaveLoginExpire(null, event.sessionId, serverRequestId = event.id),
|
||||
)
|
||||
}
|
||||
|
||||
"vault.unlock_prompt" -> {
|
||||
credentialPromptsDelegate.handleVaultUnlockExpire(
|
||||
WsEvent.VaultUnlockExpire(null, event.sessionId, serverRequestId = event.id),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message streaming ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -3452,8 +3649,8 @@ class ChatViewModel(
|
||||
*
|
||||
* The backend's clarify tool blocks the agent thread waiting for a response
|
||||
* (CLI timeout is 120s). A silent dismiss would leave the agent hanging
|
||||
* until that timeout, so we send a cancel sentinel
|
||||
* ([CLARIFY_DISMISS_RESPONSE]) over `clarify.respond` to unblock it.
|
||||
* until that timeout, so we send an empty result frame for new gateways or
|
||||
* the legacy cancel sentinel for old notification-based gateways.
|
||||
*
|
||||
* This is a *reject*, not an instruction to proceed — the agent is told no
|
||||
* answer was provided and should re-ask or back off, NOT charge ahead.
|
||||
@@ -3467,6 +3664,7 @@ class ChatViewModel(
|
||||
val sessionId = _uiState.value.currentSessionId ?: return
|
||||
val clarify = _uiState.value.clarifyRequest
|
||||
val clarifyId = clarify?.clarifyId
|
||||
val serverRequestId = clarify?.serverRequestId
|
||||
// Raw batch questions (non-empty only for true batch payloads).
|
||||
// Legacy singles keep questions empty and rely on questionId (nullable).
|
||||
val isBatch = !clarify?.questions.isNullOrEmpty()
|
||||
@@ -3476,6 +3674,10 @@ class ChatViewModel(
|
||||
addSystemMessage("Clarify dismissed — no answer sent", persist = true)
|
||||
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
if (serverRequestId != null) {
|
||||
wsClient.respondToServerRequest(serverRequestId, buildJsonObject {})
|
||||
return@launch
|
||||
}
|
||||
if (isBatch) {
|
||||
// Send dismissal for every question in the batch
|
||||
for (q in displayQuestions) {
|
||||
|
||||
@@ -164,6 +164,12 @@ object ChatWsEventReducer {
|
||||
// RpcResult is handled by the ViewModel (needs pending request context)
|
||||
is WsEvent.RpcResult -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
// Server requests are dispatched by ChatViewModel to the existing
|
||||
// prompt delegates; the reducer must remain side-effect free.
|
||||
is WsEvent.ServerRequest -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
is WsEvent.ServerRequestCancelled -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
// ApprovalRequest is handled by the ViewModel (needs active session + WS client)
|
||||
is WsEvent.ApprovalRequest -> ReducerResult(state = state, streamingState = streamingState)
|
||||
|
||||
@@ -706,6 +712,8 @@ object ChatWsEventReducer {
|
||||
questionId = event.questionId,
|
||||
multiSelect = event.multiSelect,
|
||||
questions = questions,
|
||||
serverRequestId = event.serverRequestId,
|
||||
lockedAnswers = event.lockedAnswers,
|
||||
),
|
||||
isAgentTyping = false,
|
||||
),
|
||||
|
||||
@@ -473,6 +473,7 @@ fun ClarifyBubble(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
questions.forEachIndexed { index, q ->
|
||||
val lockedAnswer = clarifyRequest.lockedAnswers[q.qid]
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
@@ -497,79 +498,105 @@ fun ClarifyBubble(
|
||||
)
|
||||
}
|
||||
|
||||
if (q.multiSelect) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "Select all that apply",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
if (q.choices.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
if (lockedAnswer != null) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
q.choices.forEach { choice ->
|
||||
val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet()
|
||||
val isSelected = selectedSet.contains(choice)
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (q.multiSelect) {
|
||||
val updated = if (isSelected) selectedSet - choice else selectedSet + choice
|
||||
selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated)
|
||||
} else {
|
||||
if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) {
|
||||
// Fast 1-tap respond for lone single-select question when no custom text is entered
|
||||
onRespondSingle(choice)
|
||||
} else {
|
||||
val updated = if (isSelected) emptySet() else setOf(choice)
|
||||
selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated)
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text(choice) },
|
||||
leadingIcon =
|
||||
if (isSelected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Answered: $lockedAnswer",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val typed = customTextByQid[q.qid].orEmpty()
|
||||
OutlinedTextField(
|
||||
value = typed,
|
||||
onValueChange = { newText ->
|
||||
customTextByQid = customTextByQid + (q.qid to newText)
|
||||
},
|
||||
label = {
|
||||
} else {
|
||||
if (q.multiSelect) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
if (q.choices.isEmpty()) {
|
||||
stringResource(R.string.message_your_response)
|
||||
} else {
|
||||
"Other (optional)"
|
||||
},
|
||||
text = "Select all that apply",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
if (q.choices.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
q.choices.forEach { choice ->
|
||||
val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet()
|
||||
val isSelected = selectedSet.contains(choice)
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (q.multiSelect) {
|
||||
val updated = if (isSelected) selectedSet - choice else selectedSet + choice
|
||||
selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated)
|
||||
} else {
|
||||
if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) {
|
||||
// Fast 1-tap respond for lone single-select question when no custom text is entered
|
||||
onRespondSingle(choice)
|
||||
} else {
|
||||
val updated = if (isSelected) emptySet() else setOf(choice)
|
||||
selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated)
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text(choice) },
|
||||
leadingIcon =
|
||||
if (isSelected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val typed = customTextByQid[q.qid].orEmpty()
|
||||
OutlinedTextField(
|
||||
value = typed,
|
||||
onValueChange = { newText ->
|
||||
customTextByQid = customTextByQid + (q.qid to newText)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
if (q.choices.isEmpty()) {
|
||||
stringResource(R.string.message_your_response)
|
||||
} else {
|
||||
"Other (optional)"
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Reference in new issue
Block a user