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@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-11 15:18:49 +03:00
1 parent 480d29e481
commit cf893585b1
6 files changed
+160

No files matched your search

@@ -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<String, String>()),
)
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 =