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:
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user