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:
1 parent
480d29e481
commit
cf893585b1
6 files changed
+160
No files matched your search
@@ -111,6 +111,17 @@ object EventParser {
|
|||||||
WsEvent.StatusUpdate(status, payload)
|
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" -> {
|
"session.updated" -> {
|
||||||
WsEvent.SessionUpdated(payload)
|
WsEvent.SessionUpdated(payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,33 @@ sealed class WsEvent {
|
|||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
) : WsEvent()
|
) : 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<String, Any?>?,
|
||||||
|
) : WsEvent()
|
||||||
|
|
||||||
// ── Fallback ─────────────────────────────────────────────────────────
|
// ── Fallback ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class Unknown(
|
data class Unknown(
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ fun ChatScreen(
|
|||||||
streamingMessage = streamingState.streamingMessage,
|
streamingMessage = streamingState.streamingMessage,
|
||||||
isThinking = streamingState.isThinking,
|
isThinking = streamingState.isThinking,
|
||||||
errorMessage = state.errorMessage,
|
errorMessage = state.errorMessage,
|
||||||
|
backgroundCompleteMessage = state.backgroundCompleteMessage,
|
||||||
isSearchActive = state.isSearchActive,
|
isSearchActive = state.isSearchActive,
|
||||||
currentSearchMatchIndex = state.currentSearchMatchIndex,
|
currentSearchMatchIndex = state.currentSearchMatchIndex,
|
||||||
searchMatchIndices = state.searchMatchIndices,
|
searchMatchIndices = state.searchMatchIndices,
|
||||||
@@ -1390,6 +1391,7 @@ private fun ChatLifecycleEffects(
|
|||||||
streamingMessage: ChatMessage?,
|
streamingMessage: ChatMessage?,
|
||||||
isThinking: Boolean,
|
isThinking: Boolean,
|
||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
|
backgroundCompleteMessage: String?,
|
||||||
isSearchActive: Boolean,
|
isSearchActive: Boolean,
|
||||||
currentSearchMatchIndex: Int,
|
currentSearchMatchIndex: Int,
|
||||||
searchMatchIndices: List<Int>,
|
searchMatchIndices: List<Int>,
|
||||||
@@ -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
|
// Clarify dialog
|
||||||
clarifyRequest?.let { clarify ->
|
clarifyRequest?.let { clarify ->
|
||||||
ClarifyDialog(
|
ClarifyDialog(
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ data class ChatUiState(
|
|||||||
/** Standalone streaming message — rendered after the main list. */
|
/** Standalone streaming message — rendered after the main list. */
|
||||||
val streamingMessage: ChatMessage? = null,
|
val streamingMessage: ChatMessage? = null,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
|
// Background job completion toast (issue #527) — non-blocking snackbar
|
||||||
|
val backgroundCompleteMessage: String? = null,
|
||||||
val clarifyRequest: ClarifyUi? = null,
|
val clarifyRequest: ClarifyUi? = null,
|
||||||
// Sudo / secret prompts — surfaced as dialogs (issue #524)
|
// Sudo / secret prompts — surfaced as dialogs (issue #524)
|
||||||
val sudoPrompt: SudoPromptUi? = null,
|
val sudoPrompt: SudoPromptUi? = null,
|
||||||
@@ -367,6 +369,15 @@ class ChatViewModel(
|
|||||||
handleSecretRequest(event)
|
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 */ }
|
else -> { /* reducer handles these */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1033,6 +1044,10 @@ class ChatViewModel(
|
|||||||
_uiState.update { it.copy(errorMessage = null) }
|
_uiState.update { it.copy(errorMessage = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearBackgroundComplete() {
|
||||||
|
_uiState.update { it.copy(backgroundCompleteMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Approval flow ───────────────────────────────────────────────────
|
// ── Approval flow ───────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun handleApprovalRequest(event: WsEvent.ApprovalRequest) {
|
private fun handleApprovalRequest(event: WsEvent.ApprovalRequest) {
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ object ChatWsEventReducer {
|
|||||||
|
|
||||||
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
|
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.SessionUpdated -> onSessionUpdated(state, streamingState)
|
||||||
|
|
||||||
is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState)
|
is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState)
|
||||||
@@ -379,6 +383,44 @@ object ChatWsEventReducer {
|
|||||||
streamingState = streamingState,
|
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 ───────────────────────
|
// ── SessionUpdated / StatusUpdate / Unknown ───────────────────────
|
||||||
|
|
||||||
private fun onSessionUpdated(
|
private fun onSessionUpdated(
|
||||||
|
|||||||
@@ -423,6 +423,61 @@ class EventParserTest {
|
|||||||
assertEquals(mapOf("session_id" to "sess-1"), (event as WsEvent.SessionUpdated).data)
|
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
|
@Test
|
||||||
fun testParseUnknownType_returnsUnknownEvent() {
|
fun testParseUnknownType_returnsUnknownEvent() {
|
||||||
val response =
|
val response =
|
||||||
|
|||||||
Reference in new issue
Block a user