fix(chat): skip live-registry context RPCs until resume confirms runtime session id (#768)

Opening a session from history fired session.context_breakdown/session.usage
with the storage session id before session.resume had registered it on the
gateway. Those RPCs resolve against the LIVE runtime registry (_sess_nowait)
and returned 4001 "session not found", which the reducer surfaced as a
spurious snackbar even though the session loaded fine.

- fetchContextUsage() now skips the two live-registry RPCs while
  runtimeSessionId is null (pre-resume window); the REST-based context
  fallbacks stay live.
- runtimeSessionId is cleared on socket drop so a stale runtime id can't be
  reused after a gateway restart/reconnect (same 4001 family).
- regression test: no context/usage RPC fires with the storage id during the
  pre-resume window; after the resume result the RPCs fire with the runtime id.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57
2026-08-02 00:45:16 +03:00
committed by GitHub
co-authored by M57
parent 5e1ee0b4e5
commit c50a178845
2 changed files with 142 additions and 32 deletions
@@ -302,6 +302,13 @@ class ChatViewModel(
status == ConnectionStatus.AUTH_EXPIRED status == ConnectionStatus.AUTH_EXPIRED
) { ) {
_uiState.update { it.copy(isLoading = false) } _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 // Fail any in-flight awaited RPCs so callers don't hang
// across the disconnect (delegated to HermesWsClient, issue #526). // across the disconnect (delegated to HermesWsClient, issue #526).
wsClient.rejectAllPending() wsClient.rejectAllPending()
@@ -1853,42 +1860,52 @@ class ChatViewModel(
// real current prompt size (drops after compression); `context_max` // real current prompt size (drops after compression); `context_max`
// is the compressor's actual window. Any failure keeps the last // is the compressor's actual window. Any failure keeps the last
// known values — never blank the meter over a transient RPC error. // known values — never blank the meter over a transient RPC error.
val rpcSessionId = runtimeSessionId ?: sessionId //
try { // These RPCs resolve the session against the gateway's LIVE runtime
val result = // registry (_sess_nowait) — the storage session id 4001s "session
sendRpcAndAwait( // not found" until session.resume has registered it. ChatScreen's
WsMethods.SESSION_CONTEXT_BREAKDOWN, // sync effect fires this immediately on session switch, before the
mapOf("session_id" to rpcSessionId), // resume result lands, so skip the RPCs until resume confirms the
) // runtime id. The REST parts below stay live (they key on the
val ctx = parseContextBreakdown(result) // storage id).
if (ctx != null) { val rpcSessionId = runtimeSessionId
_uiState.update { current -> if (rpcSessionId != null) {
current.copy( try {
usedContextTokens = val result =
ctx.contextUsed?.takeIf { it > 0L } ?: current.usedContextTokens, sendRpcAndAwait(
fullContextTokens = WsMethods.SESSION_CONTEXT_BREAKDOWN,
ctx.contextMax?.takeIf { it > 0L } ?: current.fullContextTokens, 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) { // Compression count: how many times this session has been compacted
// Best-effort: RPC error/timeout/disconnect — keep last values. // (session.usage → compressions). Feeds the "compressed ×N" badge —
} // the same usage snapshot the desktop status bar reads.
// Compression count: how many times this session has been compacted try {
// (session.usage → compressions). Feeds the "compressed ×N" badge — val usage =
// the same usage snapshot the desktop status bar reads. sendRpcAndAwait(
try { WsMethods.SESSION_USAGE,
val usage = mapOf("session_id" to rpcSessionId),
sendRpcAndAwait( )
WsMethods.SESSION_USAGE, val snapshot = parseUsageSnapshot(usage)
mapOf("session_id" to rpcSessionId), if (snapshot != null && snapshot.compressions != null) {
) _uiState.update { it.copy(compressionCount = snapshot.compressions) }
val snapshot = parseUsageSnapshot(usage) }
if (snapshot != null && snapshot.compressions != null) { } catch (_: Exception) {
_uiState.update { it.copy(compressionCount = snapshot.compressions) } // 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). // Detail-sheet accounting (cumulative REST counters, informational).
val usedResult = val usedResult =
@@ -1255,6 +1255,99 @@ class ChatViewModelTest {
assertFalse(viewModel.uiState.value.isResumeRetrying) 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<Map<String, Any>>()
every {
HermesWsClient.request(any(), capture(paramsSlot), any())
} returns CompletableDeferred<Any?>(emptyMap<String, Any?>())
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<String, Any?>(),
),
),
)
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 @Test
fun testSessionResume_boundedRetry_thenExplicitError() = fun testSessionResume_boundedRetry_thenExplicitError() =
runTest { runTest {