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 a9b879a0..7b632277 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 @@ -302,6 +302,13 @@ class ChatViewModel( status == ConnectionStatus.AUTH_EXPIRED ) { _uiState.update { it.copy(isLoading = false) } + // The runtime session id is only valid while the socket + // owns it — a dropped connection may mean the gateway + // closed/pruned the session (or restarted, wiping the + // runtime registry). Clear it so session-scoped RPCs can't + // fire with a stale id and 4001 "session not found"; + // handleGatewayReady rebinds it on the re-resume. + runtimeSessionId = null // Fail any in-flight awaited RPCs so callers don't hang // across the disconnect (delegated to HermesWsClient, issue #526). wsClient.rejectAllPending() @@ -1853,42 +1860,52 @@ class ChatViewModel( // real current prompt size (drops after compression); `context_max` // is the compressor's actual window. Any failure keeps the last // known values — never blank the meter over a transient RPC error. - val rpcSessionId = runtimeSessionId ?: sessionId - try { - val result = - sendRpcAndAwait( - WsMethods.SESSION_CONTEXT_BREAKDOWN, - mapOf("session_id" to rpcSessionId), - ) - val ctx = parseContextBreakdown(result) - if (ctx != null) { - _uiState.update { current -> - current.copy( - usedContextTokens = - ctx.contextUsed?.takeIf { it > 0L } ?: current.usedContextTokens, - fullContextTokens = - ctx.contextMax?.takeIf { it > 0L } ?: current.fullContextTokens, + // + // These RPCs resolve the session against the gateway's LIVE runtime + // registry (_sess_nowait) — the storage session id 4001s "session + // not found" until session.resume has registered it. ChatScreen's + // sync effect fires this immediately on session switch, before the + // resume result lands, so skip the RPCs until resume confirms the + // runtime id. The REST parts below stay live (they key on the + // storage id). + val rpcSessionId = runtimeSessionId + if (rpcSessionId != null) { + try { + val result = + sendRpcAndAwait( + WsMethods.SESSION_CONTEXT_BREAKDOWN, + mapOf("session_id" to rpcSessionId), ) + val ctx = parseContextBreakdown(result) + if (ctx != null) { + _uiState.update { current -> + current.copy( + usedContextTokens = + ctx.contextUsed?.takeIf { it > 0L } ?: current.usedContextTokens, + fullContextTokens = + ctx.contextMax?.takeIf { it > 0L } ?: current.fullContextTokens, + ) + } } + } catch (_: Exception) { + // Best-effort: RPC error/timeout/disconnect — keep last values. } - } catch (_: Exception) { - // Best-effort: RPC error/timeout/disconnect — keep last values. - } - // Compression count: how many times this session has been compacted - // (session.usage → compressions). Feeds the "compressed ×N" badge — - // the same usage snapshot the desktop status bar reads. - try { - val usage = - sendRpcAndAwait( - WsMethods.SESSION_USAGE, - mapOf("session_id" to rpcSessionId), - ) - val snapshot = parseUsageSnapshot(usage) - if (snapshot != null && snapshot.compressions != null) { - _uiState.update { it.copy(compressionCount = snapshot.compressions) } + // Compression count: how many times this session has been compacted + // (session.usage → compressions). Feeds the "compressed ×N" badge — + // the same usage snapshot the desktop status bar reads. + try { + val usage = + sendRpcAndAwait( + WsMethods.SESSION_USAGE, + mapOf("session_id" to rpcSessionId), + ) + val snapshot = parseUsageSnapshot(usage) + if (snapshot != null && snapshot.compressions != null) { + _uiState.update { it.copy(compressionCount = snapshot.compressions) } + } + } catch (_: Exception) { + // Best-effort: keep the last known badge value. } - } catch (_: Exception) { - // Best-effort: keep the last known badge value. } // Detail-sheet accounting (cumulative REST counters, informational). val usedResult = 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 6ff06cf2..8dc5ce1a 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 @@ -1255,6 +1255,99 @@ class ChatViewModelTest { assertFalse(viewModel.uiState.value.isResumeRetrying) } + /** + * Regression for the spurious "Error: JsonRpcError(code=4001, message=session + * not found, data=null)" snackbar when opening a session from history: the + * chat sync effect fires fetchContextUsage() immediately on session switch, + * BEFORE session.resume has registered the session on the gateway. The + * context/usage RPCs resolve against the gateway's LIVE runtime registry + * (_sess_nowait) and would 4001 on the storage id — so they must be skipped + * until the resume result confirms the runtime id (which is then used). + */ + @Test + fun testSwitchSession_contextRpcSkippedUntilResumeConfirmsRuntimeId() = + runTest { + stubSession456Rests(success = true) + + // Awaited live-registry RPCs (sendRpcAndAwait → HermesWsClient.request). + // Completed deferred so the awaits resolve; capture the params. Installed + // BEFORE createViewModelWithSession so the SESSION_CREATE-era + // fetchContextUsage() call is stubbed too (otherwise its unstubbed + // invocation is still recorded and pollutes the verify(exactly = 0)). + val paramsSlot = slot>() + every { + HermesWsClient.request(any(), capture(paramsSlot), any()) + } returns CompletableDeferred(emptyMap()) + + val (viewModel, _) = createViewModelWithSession() + + // Capture the session.resume request id so its result can be delivered. + var resumeRequestId: String? = null + every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers { + reqCount++ + val id = "resume-$reqCount" + arg<((String) -> Unit)?>(2)?.invoke(id) + resumeRequestId = id + id + } + + // Switch to session-456: runtimeSessionId resets to null until the + // resume result lands — the storage id is not (yet) live on the + // gateway. ChatScreen's sync effect fires fetchContextUsage() + // immediately on session switch — simulate it while the resume + // result is still in flight. The live-registry RPCs must NOT fire + // with the stale storage id (the gateway would 4001). + viewModel.switchSession("session-456") + advanceUntilIdle() + viewModel.fetchContextUsage() + advanceUntilIdle() + + verify(exactly = 0) { + HermesWsClient.request( + WsMethods.SESSION_CONTEXT_BREAKDOWN, + match { it["session_id"] == "session-456" }, + any(), + ) + } + verify(exactly = 0) { + HermesWsClient.request( + WsMethods.SESSION_USAGE, + match { it["session_id"] == "session-456" }, + any(), + ) + } + + // Resume result lands → runtime id confirmed → the RPCs fire with it. + val resumeId = checkNotNull(resumeRequestId) + mockEventsFlow.emit( + WsEvent.RpcResult( + resumeId, + mapOf( + "session_id" to "runtime-456", + "resumed" to "session-456", + "info" to emptyMap(), + ), + ), + ) + advanceUntilIdle() + + verify { + HermesWsClient.request( + WsMethods.SESSION_CONTEXT_BREAKDOWN, + match { it["session_id"] == "runtime-456" }, + any(), + ) + } + verify { + HermesWsClient.request( + WsMethods.SESSION_USAGE, + match { it["session_id"] == "runtime-456" }, + any(), + ) + } + assertEquals("runtime-456", paramsSlot.captured["session_id"]) + } + @Test fun testSessionResume_boundedRetry_thenExplicitError() = runTest {