From cf893585b11d9778857d3527aaaad7a5cbd77301 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:18:49 +0300 Subject: [PATCH] feat(#527): surface gateway error + background.complete WebSocket events (#550) Mobile was silently dropping two event types the gateway emits. This adds parsing + UI surfacing for both, bringing mobile to parity with desktop (which shows gateway errors as red toasts). - `error` (backend crash) -> WsEvent.GatewayError, surfaced in the existing error banner (mirrors how RpcError is handled). - `background.complete` (scheduled job finished) -> WsEvent.BackgroundComplete, surfaced as a non-blocking snackbar in ChatScreen. Changes: - WsEvent.kt: add GatewayError(message?) + BackgroundComplete(data?) sealed subtypes. - EventParser.kt: handle "error" + "background.complete"; unknowns still fall through to Unknown (no behavior change for existing event types). - ChatWsEventReducer.kt: onGatewayError (sets errorMessage, clears loading) + onBackgroundComplete (sets backgroundCompleteMessage); mirrors onRpcError. - ChatViewModel.kt: backgroundCompleteMessage state field, clearBackgroundComplete(), handleWsEvent branches (ViewModel owns the snackbar trigger). - ChatScreen.kt: pass backgroundCompleteMessage to ChatLifecycleEffects + a LaunchedEffect that shows the snackbar and calls clearBackgroundComplete(). - EventParserTest.kt: cover both new events (incl. missing-message case). Pure additive; sealed class + exhaustive when() preserved. Verified: compileDebugKotlin + ktlintCheck green; EventParserTest passes. Closes #527 Co-authored-by: M57 --- .../m57/hermescontrol/data/ws/EventParser.kt | 11 ++++ .../com/m57/hermescontrol/data/ws/WsEvent.kt | 27 +++++++++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 10 ++++ .../hermescontrol/ui/chat/ChatViewModel.kt | 15 +++++ .../ui/chat/ChatWsEventReducer.kt | 42 ++++++++++++++ .../hermescontrol/data/ws/EventParserTest.kt | 55 +++++++++++++++++++ 6 files changed, 160 insertions(+) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index 72a38fac..c9cc5b0d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -111,6 +111,17 @@ object EventParser { WsEvent.StatusUpdate(status, payload) } + "error" -> { + val message = + payload?.get("message") as? String + ?: payload?.get("error") as? String + WsEvent.GatewayError(message) + } + + "background.complete" -> { + WsEvent.BackgroundComplete(payload) + } + "session.updated" -> { WsEvent.SessionUpdated(payload) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 4d0d234c..01335f4a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -125,6 +125,33 @@ sealed class WsEvent { val sessionId: String?, ) : WsEvent() + // ── Gateway-level errors ─────────────────────────────────────────── + + /** + * Backend/unhandled failure surfaced by the gateway. + * + * Desktop shows these as red toasts (the gateway also writes + * `[gateway.error]` lines to the console). Mobile was previously + * dropping this event, so a crashing turn just silently stopped with no + * explanation. Issue #527 surfaces it in the existing error banner. + */ + data class GatewayError( + val message: String?, + ) : WsEvent() + + // ── Background job completion ────────────────────────────────────── + + /** + * A scheduled/background job finished on the gateway. + * + * Desktop shows a "Background job finished" toast. Mobile surfaces this as + * a non-blocking snackbar. The payload may carry `label`/`name` describing + * the job. Issue #527. + */ + data class BackgroundComplete( + val data: Map?, + ) : WsEvent() + // ── Fallback ───────────────────────────────────────────────────────── data class Unknown( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 2d9e7d9d..953a2fd9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -277,6 +277,7 @@ fun ChatScreen( streamingMessage = streamingState.streamingMessage, isThinking = streamingState.isThinking, errorMessage = state.errorMessage, + backgroundCompleteMessage = state.backgroundCompleteMessage, isSearchActive = state.isSearchActive, currentSearchMatchIndex = state.currentSearchMatchIndex, searchMatchIndices = state.searchMatchIndices, @@ -1390,6 +1391,7 @@ private fun ChatLifecycleEffects( streamingMessage: ChatMessage?, isThinking: Boolean, errorMessage: String?, + backgroundCompleteMessage: String?, isSearchActive: Boolean, currentSearchMatchIndex: Int, searchMatchIndices: List, @@ -1487,6 +1489,14 @@ private fun ChatLifecycleEffects( } } + // Show background-complete as a non-blocking snackbar (issue #527) + LaunchedEffect(backgroundCompleteMessage) { + backgroundCompleteMessage?.let { message -> + snackbarHostState.showSnackbar(message) + viewModel.clearBackgroundComplete() + } + } + // Clarify dialog clarifyRequest?.let { clarify -> ClarifyDialog( 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 8052c309..6c58030c 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 @@ -56,6 +56,8 @@ data class ChatUiState( /** Standalone streaming message — rendered after the main list. */ val streamingMessage: ChatMessage? = null, val errorMessage: String? = null, + // Background job completion toast (issue #527) — non-blocking snackbar + val backgroundCompleteMessage: String? = null, val clarifyRequest: ClarifyUi? = null, // Sudo / secret prompts — surfaced as dialogs (issue #524) val sudoPrompt: SudoPromptUi? = null, @@ -367,6 +369,15 @@ class ChatViewModel( handleSecretRequest(event) } + is WsEvent.GatewayError -> { + // Reducer already set errorMessage; no extra VM work needed. + } + + is WsEvent.BackgroundComplete -> { + // Reducer already set backgroundCompleteMessage; the UI observes + // it via a LaunchedEffect and triggers the snackbar. + } + else -> { /* reducer handles these */ } } } @@ -1033,6 +1044,10 @@ class ChatViewModel( _uiState.update { it.copy(errorMessage = null) } } + fun clearBackgroundComplete() { + _uiState.update { it.copy(backgroundCompleteMessage = null) } + } + // ── Approval flow ─────────────────────────────────────────────────── private fun handleApprovalRequest(event: WsEvent.ApprovalRequest) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 6c828d43..2bb65f78 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -71,6 +71,10 @@ object ChatWsEventReducer { is WsEvent.RpcError -> onRpcError(state, streamingState, event) + is WsEvent.GatewayError -> onGatewayError(state, streamingState, event) + + is WsEvent.BackgroundComplete -> onBackgroundComplete(state, streamingState, event) + is WsEvent.SessionUpdated -> onSessionUpdated(state, streamingState) is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState) @@ -379,6 +383,44 @@ object ChatWsEventReducer { streamingState = streamingState, ) + // ── GatewayError ────────────────────────────────────────────────── + // Backend/unhandled failure surfaced by the gateway (issue #527). + // Mirrors onRpcError: surfaces the message in the existing error banner. + private fun onGatewayError( + state: ChatUiState, + streamingState: StreamingState, + event: WsEvent.GatewayError, + ): ReducerResult = + ReducerResult( + state = + state.copy( + isLoading = false, + errorMessage = "⚠️ Backend error: ${event.message ?: "Unknown gateway error"}", + ), + streamingState = streamingState, + ) + + // ── BackgroundComplete ──────────────────────────────────────────── + // A scheduled/background job finished (issue #527). The ViewModel turns + // this into a non-blocking snackbar. No state mutation beyond passing it + // through; the ViewModel owns the snackbar trigger. + private fun onBackgroundComplete( + state: ChatUiState, + streamingState: StreamingState, + event: WsEvent.BackgroundComplete, + ): ReducerResult { + val message = + when (val label = event.data?.get("label")) { + is String -> label + else -> event.data?.get("name") as? String + }?.let { "✅ Background job finished: $it" } + ?: "✅ Background job finished" + return ReducerResult( + state = state.copy(backgroundCompleteMessage = message), + streamingState = streamingState, + ) + } + // ── SessionUpdated / StatusUpdate / Unknown ─────────────────────── private fun onSessionUpdated( diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index 089ee9d4..6298352d 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -423,6 +423,61 @@ class EventParserTest { assertEquals(mapOf("session_id" to "sess-1"), (event as WsEvent.SessionUpdated).data) } + @Test + fun testParseGatewayError_returnsGatewayErrorEvent() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = mapOf("type" to "error", "payload" to mapOf("message" to "boom")), + ) + val event = EventParser.parse(response, "") + assertTrue(event is WsEvent.GatewayError) + assertEquals("boom", (event as WsEvent.GatewayError).message) + } + + @Test + fun testParseGatewayError_missingMessage_returnsNullMessage() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = mapOf("type" to "error", "payload" to mapOf()), + ) + val event = EventParser.parse(response, "") + assertTrue(event is WsEvent.GatewayError) + assertEquals(null, (event as WsEvent.GatewayError).message) + } + + @Test + fun testParseBackgroundComplete_returnsBackgroundCompleteEvent() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "background.complete", + "payload" to mapOf("label" to "nightly backup"), + ), + ) + val event = EventParser.parse(response, "") + assertTrue(event is WsEvent.BackgroundComplete) + assertEquals( + "nightly backup", + (event as WsEvent.BackgroundComplete).data?.get("label"), + ) + } + @Test fun testParseUnknownType_returnsUnknownEvent() { val response =