From aff1b7ed3a75bd5d07d65fbb72bdde41dbf456a3 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:15:08 +0300 Subject: [PATCH] fix(chat): use runtime session ID in approval RPCs to prevent 4001 session not found --- .../hermescontrol/ui/chat/ChatViewModel.kt | 26 +++++-- .../ui/chat/ChatViewModelTest.kt | 69 +++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 405d54f3..3c51ef67 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -947,7 +947,12 @@ class ChatViewModel( rid != null && _uiState.value.messages.any { it.approvalInfo?.requestId == rid } if (!alreadyShown) { - handleApprovalRequest(parseApprovalMap(pendingApproval, _uiState.value.currentSessionId)) + handleApprovalRequest( + parseApprovalMap( + pendingApproval, + runtimeSessionId ?: _uiState.value.currentSessionId, + ), + ) } } } @@ -1241,10 +1246,16 @@ class ChatViewModel( rid != null && _uiState.value.messages.any { it.approvalInfo?.requestId == rid } if (!alreadyShown) { - handleApprovalRequest(parseApprovalMap(pendingApproval, sessionId)) + handleApprovalRequest( + parseApprovalMap( + pendingApproval, + runtimeSessionId ?: sessionId, + ), + ) } } - if (sessionId != null) replayPendingApproval(sessionId) + val activeSessionId = runtimeSessionId ?: sessionId + if (activeSessionId != null) replayPendingApproval(activeSessionId) } WsMethods.SESSION_INTERRUPT -> { @@ -3648,7 +3659,7 @@ class ChatViewModel( // Desktop parity (`prompts.ts` receiveApprovalRequest): ack the render // so the backend knows this client holds the prompt. Fire-and-forget. val requestId = event.requestId - val sessionId = event.sessionId ?: _uiState.value.currentSessionId + val sessionId = runtimeSessionId ?: event.sessionId ?: _uiState.value.currentSessionId if (requestId != null && sessionId != null) { viewModelScope.launch(ioDispatcher) { runCatching { @@ -3715,10 +3726,11 @@ class ChatViewModel( * after resume and after each respond (the queue can hold more). */ private fun replayPendingApproval(sessionId: String) { + val targetSessionId = runtimeSessionId ?: sessionId viewModelScope.launch(ioDispatcher) { wsClient.send( method = WsMethods.APPROVAL_PENDING, - params = mapOf("session_id" to sessionId), + params = mapOf("session_id" to targetSessionId), onSent = { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) }, ) } @@ -3729,7 +3741,7 @@ class ChatViewModel( val approvals = (result as? Map<*, *>)?.get("approvals") as? List<*> val first = approvals?.filterIsInstance>()?.firstOrNull() ?: return val requestId = first["request_id"] as? String ?: return - val sessionId = _uiState.value.currentSessionId + val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId // Don't duplicate a prompt already on screen. val alreadyShown = _uiState.value.messages.any { it.approvalInfo?.requestId == requestId } @@ -3740,7 +3752,7 @@ class ChatViewModel( fun respondToApproval(action: String) { val state = _uiState.value val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return - val sessionId = state.currentSessionId ?: return + val sessionId = runtimeSessionId ?: state.currentSessionId ?: return // Desktop sends `once` for a single run; legacy mobile sent `approve` // (any non-deny still unblocks, but stay on-spec going forward). val choice = if (action == "approve") "once" else action diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index cf10e240..eb8234d5 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -3196,6 +3196,75 @@ class ChatViewModelTest { } } + @Test + fun testApprovalFlow_prefersRuntimeSessionIdOverStorageId() = + runTest { + val viewModel = createViewModel() + advanceUntilIdle() + + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.RpcResult( + "req-id-3", + mapOf( + "session_id" to "runtime-sid-999", + "stored_session_id" to "storage-uuid-111", + ), + ), + ) + advanceUntilIdle() + + assertEquals("storage-uuid-111", viewModel.uiState.value.currentSessionId) + + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "rm -rf /data", + description = "Dangerous operation", + patternKeys = null, + sessionId = null, + requestId = "req-runtime-test", + choices = listOf("once", "deny"), + ), + ) + advanceUntilIdle() + + verify { + HermesWsClient.send( + WsMethods.APPROVAL_RECEIVED, + withArg { params -> + assertEquals("runtime-sid-999", params["session_id"]) + assertEquals("req-runtime-test", params["request_id"]) + }, + any(), + ) + } + + viewModel.respondToApproval("once") + advanceUntilIdle() + + verify { + HermesWsClient.send( + WsMethods.APPROVAL_RESPOND, + withArg { params -> + assertEquals("runtime-sid-999", params["session_id"]) + assertEquals("once", params["choice"]) + assertEquals("req-runtime-test", params["request_id"]) + }, + any(), + ) + HermesWsClient.send( + WsMethods.APPROVAL_PENDING, + withArg { params -> + assertEquals("runtime-sid-999", params["session_id"]) + }, + any(), + ) + } + } + // ── Sudo / secret prompt flow (issue #524) ─────────────────────────── @Test